Tutorials

How to Generate YouTube Thumbnails from an API and Set Them on Every Video

How to Generate YouTube Thumbnails from an API and Set Them on Every Video

A course platform with 80 lessons has 80 thumbnails to make. A weekly podcast that cross-posts to YouTube has one to make every Thursday, forever. A tutorial channel shipping three videos a week has more thumbnails in its backlog than anyone wants to open a design tool for. The thumbnail is the same layout every time with three fields changed, and it is still being built by hand.

This article covers the whole loop: what YouTube will accept, rendering a thumbnail from JSON through a template, rendering one from your own HTML when the template's layout is not your channel's style, and pushing the file onto the video with the YouTube Data API. Every image below is the actual output of the call above it.

What YouTube accepts, and where it draws over your image

YouTube's spec is short. Upload a 16:9 image, ideally 1280x720 and at least 640 wide, as JPG, PNG, GIF or BMP, under 2 MB. The Data API narrows that slightly: the thumbnails.set endpoint takes image/jpeg or image/png and enforces the same 2 MB cap. Custom thumbnails also require a verified channel, which is a one-off phone verification in YouTube Studio, so do that before the first API call rather than after the first 403.

The spec is the easy part. The constraints that actually shape the design come from where YouTube renders the file:

  • The duration badge sits in the bottom-right corner on every surface. Anything placed there is covered.

  • The watch-next sidebar and the mobile feed show the thumbnail at under 170 pixels wide. A title that reads at 1280 wide and disappears at 168 is not a title.

  • YouTube layers its own play button and hover state over the image. Low-contrast gradients that look fine in isolation wash out underneath them.

If you have built Open Graph images with a safe zone the discipline is familiar: the platform draws over part of your image, so the design keeps the payload out of that region. For YouTube the region is the bottom-right corner, and the payload is three to five large words.

Route 1: render from JSON with the template endpoint

The YouTube thumbnail template takes the fields a thumbnail actually has and returns the PNG. One required input, title, and seven optional ones:

curl -X POST https://app.html2img.com/api/v1/templates/youtube-thumbnail \
  -H "X-API-Key: $HTML2IMG_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "title": "Stop hand-rolling your auth",
    "eyebrow": "LESSON 12 · SECURITY",
    "duration": "18:04",
    "host_name": "Linden Academy",
    "background_image_url": "https://picsum.photos/seed/lesson12/1280/720",
    "background_color": "#0F172A",
    "accent_color": "#FACC15"
  }'

The response is the standard envelope with a CDN URL:

{
  "success": true,
  "id": "1f3d9641-c948-44ef-a8c4-7798dcf00bb9",
  "template": "youtube-thumbnail",
  "expires_at": null,
  "credits_remaining": 100,
  "url": "https://i.html2img.com/image-1789129678446-987523.png"
}

And this is the file at that URL:

A YouTube thumbnail rendered from JSON by the template endpoint: a yellow eyebrow reading LESSON 12 · SECURITY, the title Stop hand-rolling your auth in large white type over a darkened photo, the host name bottom-left and an 18:04 duration badge bottom-right

A few things the render gets right without being asked. The title fills the left two-thirds and stops well short of the corner. The duration value renders as a badge in the bottom-right, exactly where YouTube will put its own, so you see in the render what the grid will show and nothing important is hiding underneath it. The background_image_url is darkened behind the type, which is why white text stays readable over a photo you have not seen.

The file came back at 2560x1440, which is the template rendering at 2x. That is still 16:9, YouTube resizes it on upload, and at 855 KB it sits inside the 2 MB cap with room to spare. A dense photographic background is the one thing that can push a 2x render towards the limit, so if a channel's stills are busy, send a lighter or more compressed image in background_image_url.

host_avatar_url is the input left out above. Send it and the template masks the image into a small circle beside the host name in the bottom-left, the way a channel avatar sits under a video, rather than as a large hero face. Leave it out for a course or a brand channel and the slot collapses rather than rendering a placeholder. The template reference lists every input with its default, and the YouTube thumbnail tool runs the same template in a browser if you want to find the right colours before writing the request.

Route 2: your own layout through the HTML endpoint

The template is one design. A channel with an established thumbnail style, or a course platform that wants the module colour and lesson number where its students expect them, needs its own markup. That is the HTML endpoint at 1280x720:

curl -X POST https://app.html2img.com/api/html \
  -H "X-API-Key: $HTML2IMG_API_KEY" \
  -H "Content-Type: application/json" \
  -d @- <<'JSON'
{
  "width": 1280,
  "height": 720,
  "html": "<!doctype html><html><head><meta charset=\"utf-8\"><style>@import url('https://fonts.googleapis.com/css2?family=Manrope:wght@600;800&family=JetBrains+Mono:wght@700&display=swap');*{box-sizing:border-box;margin:0}body{width:1280px;height:720px;overflow:hidden;position:relative;background:#0B1220;color:#fff;font-family:Manrope,sans-serif}.glow{position:absolute;inset:0;background:radial-gradient(900px 600px at 85% 20%,rgba(37,99,235,.55),transparent 65%)}.bar{position:absolute;left:0;top:0;bottom:0;width:22px;background:#FACC15}.wrap{position:absolute;left:90px;top:72px;right:72px;bottom:72px;display:flex;flex-direction:column;justify-content:space-between}.eyebrow{display:inline-flex;align-items:center;gap:18px;font-family:'JetBrains Mono',monospace;font-weight:700;font-size:30px;letter-spacing:.12em}.num{background:#FACC15;color:#0B1220;padding:10px 22px;border-radius:10px}.mod{color:#FACC15}h1{font-size:118px;font-weight:800;line-height:.98;letter-spacing:-.03em;max-width:1000px}h1 span{color:#FACC15}.foot{display:flex;align-items:center;justify-content:space-between}.brand{display:flex;align-items:center;gap:16px;font-weight:800;font-size:34px}.brand i{width:44px;height:44px;border-radius:12px;background:#FACC15;display:block}.safe{font-family:'JetBrains Mono',monospace;font-size:22px;color:rgba(255,255,255,.35);letter-spacing:.08em}</style></head><body><div class=\"glow\"></div><div class=\"bar\"></div><div class=\"wrap\"><div class=\"eyebrow\"><span class=\"num\">12</span><span class=\"mod\">SECURITY MODULE</span></div><h1>Stop hand-rolling <span>your auth</span></h1><div class=\"foot\"><div class=\"brand\"><i></i>Linden Academy</div><div class=\"safe\">PART 3 OF 5</div></div></div></body></html>"
}
JSON

That produced this, a native 1280x720 PNG at 367 KB:

A custom course-lesson YouTube thumbnail rendered from HTML: a yellow lesson number 12 and SECURITY MODULE label top-left, the title Stop hand-rolling your auth at 118px with the last two words in yellow, a Linden Academy brand mark bottom-left and a faint PART 3 OF 5 label bottom-right

The markup is doing three deliberate things. The title is 118px, which is large enough that at sidebar size it is still three readable words. The only content in the bottom-right corner is a low-priority PART 3 OF 5 at 35% opacity, because the duration badge will cover it and nothing is lost when it does. And the whole layout is driven by four values, the number, the module, the title and the part label, so the request builder is a string template over a lesson record.

The rules for markup that renders reliably are the same as for any HTML to image request: set width and height on the request and on body, load fonts via Google Fonts @import, keep the design to inline CSS and inline SVG, and do not load JavaScript libraries from a CDN. Leave dpi at the default of 1 for a thumbnail. A 2x render of a photographic background is the quickest way to hit the 2 MB cap for no visible benefit on a surface that displays at 168 pixels wide.

Pushing the file onto the video

The render gives you a URL. YouTube wants the bytes. The Data API's thumbnails.set method takes a media upload against a videoId, so the pipeline is: render, fetch the PNG, stream it to YouTube. In Node with the official client:

import { google } from 'googleapis';
import { Readable } from 'node:stream';

// oauth2Client is an authorised OAuth2 client with the
// https://www.googleapis.com/auth/youtube.upload scope.
const youtube = google.youtube({ version: 'v3', auth: oauth2Client });

async function setLessonThumbnail(videoId, lesson) {
  const render = await fetch(
    'https://app.html2img.com/api/v1/templates/youtube-thumbnail',
    {
      method: 'POST',
      headers: {
        'X-API-Key': process.env.HTML2IMG_API_KEY,
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({
        title: lesson.title,
        eyebrow: `LESSON ${lesson.number} · ${lesson.module.toUpperCase()}`,
        duration: lesson.duration,
        host_name: 'Linden Academy',
        background_image_url: lesson.stillUrl,
        background_color: '#0F172A',
        accent_color: '#FACC15',
      }),
    },
  ).then((r) => r.json());

  if (!render.success) {
    throw new Error(`Render failed: ${JSON.stringify(render.errors ?? render)}`);
  }

  const png = Buffer.from(await fetch(render.url).then((r) => r.arrayBuffer()));

  if (png.byteLength > 2 * 1024 * 1024) {
    throw new Error(`Thumbnail for ${videoId} is ${png.byteLength} bytes, over YouTube's 2 MB cap`);
  }

  await youtube.thumbnails.set({
    videoId,
    media: { mimeType: 'image/png', body: Readable.from(png) },
  });

  return render.url;
}

Two YouTube-side numbers decide how you batch this. Each thumbnails.set call costs roughly 50 quota units, and a Data API project starts with 10,000 units a day, so the ceiling is about 200 thumbnails a day before you request a quota increase. Separately, YouTube rate-limits thumbnail uploads per channel and returns 429 uploadRateLimitExceeded when a channel has set too many in a short window. Treat it as a back-off signal, not a failure: wait, then retry the same video.

Setting the OAuth client up is Google's documentation, not this article's. The one thing to get right is the scope: youtube.upload is enough to set thumbnails, and it is the least privilege the pipeline needs.

Rendering the back catalogue

For a new video the function above runs once at publish time. For an existing library you run it in a loop, and two things keep that loop cheap.

The first is idempotency. Store the render URL against the video ID together with a hash of the inputs that produced it. On the next run, a lesson whose title, module and duration are unchanged is skipped entirely: no render credit, no quota units. A lesson that has been renamed gets one new render and one new upload.

import { createHash } from 'node:crypto';

const key = createHash('sha256')
  .update(JSON.stringify([lesson.number, lesson.module, lesson.title, lesson.duration, lesson.stillUrl]))
  .digest('hex');

if (store.get(videoId)?.inputHash === key) continue;

const url = await setLessonThumbnail(videoId, lesson);
store.set(videoId, { inputHash: key, url });

The second is ordering. YouTube's 50 units per call means a 400-video library is two days of default quota, so run the loop against the videos that get traffic first. videos.list with part=statistics returns view counts, and the top 200 by views get their thumbnails today while the long tail waits until tomorrow. The same list gives you contentDetails.duration in ISO 8601, which is where the duration input comes from rather than a hand-typed string.

The render side does not need the same care. Templates cost one credit per render regardless of complexity, and the account endpoint reports the balance without spending one, so a pre-flight check before a large batch is a single GET /api/me.

Reuse the same data on the surfaces beside YouTube

A lesson record that renders a thumbnail also renders the podcast episode card for the audio feed and the Instagram story that announces it, from the same four fields, in the same publish step. Course platforms already doing this for completion certificates will recognise the pattern: one source of truth, several rendered outputs, no design tool anywhere in the pipeline. If the videos are built in CI, the GitHub Actions setup for Open Graph images transfers directly, with the template slug swapped.


Need thumbnails, episode cards or certificates rendered from data without running a browser yourself? Browse the templates gallery or read the docs to get started.

Mike Griffiths

Written by

Mike has spent the last 20 years crafting software solutions for all kinds of amazing businesses. He specializes in building digital products and APIs that make a real difference. As an expert in Laravel and a voting member on the PHP language, Mike helps shape the future of web development.

More articles by Mike Griffiths