How to Generate Quote Cards from an API for Testimonials, Pull Quotes and Podcast Clips
A SaaS with forty customer testimonials has forty quote cards to make. A publication that shares two pull quotes per article needs a card every time a piece goes out. A podcast that clips the best line from each episode needs one a week for as long as the show runs. Each card is the same layout with the words swapped, and it is still being opened in a design tool, typed out, exported and uploaded by hand.
This article shows how to render quote cards from an API instead: one call per quote, a hosted PNG back, and a loop that runs the whole backlog while you do something else. Every image below is the actual output of the request above it.
What a quote card has to get right
A quote card looks simple and hides three problems.
The first is length. A testimonial can be eleven words or fifty, and the card has to look deliberate at both ends. A fixed font size either leaves a short quote floating in empty space or pushes a long one off the bottom. The type has to scale with the text.
The second is attribution. Name, role, company and sometimes a face or a logo, laid out so the quote stays the hero and the credit reads as a caption. This is the part that drifts when different people make the cards, and drift is what makes a feed look like three brands instead of one.
The third is format. A 1200x1200 square posts to Instagram, LinkedIn and X without recropping, which is why it is the default. But a card destined for a link preview has to be 1200x630, and a card for Stories has to be 1080x1920 with the payload kept out of the top and bottom where the platform draws its own chrome. If you have built Open Graph images with a safe zone the discipline carries over exactly.
The template route below handles the first two problems for you. The custom HTML route handles all three, at the cost of writing the markup once.
Route 1: render from JSON with the template endpoint
The Quote Card template takes two required inputs, quote and attribution_name, and six optional ones for the role, an avatar, a brand name, a logo and the two colours:
curl -X POST https://app.html2img.com/api/v1/templates/quote-card \
-H "X-API-Key: $HTML2IMG_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"quote": "We replaced a designer-in-the-loop process with one API call. The cards went out the same afternoon the quotes came in, and nobody could tell they were not hand made.",
"attribution_name": "Priya Raman",
"attribution_role": "Head of Product, Fieldline",
"brand_name": "Fieldline",
"background_color": "#FAF7F0",
"accent_color": "#2563EB"
}' The response is the standard envelope with a CDN URL:
{
"success": true,
"id": "152d3ad1-5fb3-44e0-b7c3-328c45332aeb",
"template": "quote-card",
"expires_at": null,
"credits_remaining": 100,
"url": "https://i.html2img.com/image-1789228709438-533456.png"
} And this is the file at that URL:

A few things the render does without being asked. The quote is 30 words and the type has been sized so it fills the middle of the card in six lines with even margins; send a ten-word quote and it comes back larger, not lonelier. The accent_color shows up as the rule under the quote and the brand name at the bottom, and nowhere else, so a single hex value keeps the card on-brand without letting the colour take over. The decorative quotation marks are tinted from the background, not the accent, which is why they read as texture rather than competing with the words.
The file came back at 2400x2400, the template rendering at 2x, and at 1.5 MB. That is comfortably inside what every social platform accepts, and it is sharp on a retina feed. The template always renders at 2x, so if you need a lighter file for a page or an email, the HTML route below is the one to use: it renders at 1x unless you ask for more.
Two inputs are left out above. avatar_url takes a photo of the person and masks it into a circle beside the attribution, which is the right call for testimonials and podcast guests and the wrong one for an anonymous pull quote, so leave it out and the slot collapses. logo_url replaces the text brand name with your mark. Both are fetched at render time, so they need to be publicly reachable URLs. The template reference lists every input with its default, and the Quote Card tool runs the same template in the browser if you want to settle on colours before writing the request.
Route 2: your own layout through the HTML endpoint
The template is one design. A brand with an established testimonial style, a dark card with a serif face and a monogram avatar, say, needs its own markup. That is the HTML endpoint, and because you set the dimensions it is also how you get the 1200x630 version for a link preview:
curl -X POST https://app.html2img.com/api/html \
-H "X-API-Key: $HTML2IMG_API_KEY" \
-H "Content-Type: application/json" \
-d @- <<'JSON'
{
"width": 1200,
"height": 630,
"html": "<!doctype html><html><head><meta charset=\"utf-8\"><style>@import url('https://fonts.googleapis.com/css2?family=Fraunces:opsz,[email protected],600&family=Manrope:wght@500;700&display=swap');*{box-sizing:border-box;margin:0}body{width:1200px;height:630px;overflow:hidden;position:relative;background:#0B1220;color:#fff;font-family:Manrope,sans-serif}.glow{position:absolute;inset:0;background:radial-gradient(800px 500px at 90% 10%,rgba(37,99,235,.45),transparent 65%)}.wrap{position:absolute;inset:64px 72px;display:flex;flex-direction:column;justify-content:space-between}.mark{font-family:Fraunces,serif;font-size:160px;line-height:.6;color:#2563EB;height:70px}blockquote{font-family:Fraunces,serif;font-weight:600;font-size:44px;line-height:1.25;letter-spacing:-.01em;max-width:1000px}.who{display:flex;align-items:center;gap:20px}.avatar{width:64px;height:64px;border-radius:50%;background:#2563EB;display:flex;align-items:center;justify-content:center;font-weight:700;font-size:24px}.name{font-weight:700;font-size:22px}.role{font-size:18px;color:rgba(255,255,255,.6);margin-top:4px}.brand{margin-left:auto;font-weight:700;font-size:16px;letter-spacing:.2em;text-transform:uppercase;color:rgba(255,255,255,.45)}</style></head><body><div class=\"glow\"></div><div class=\"wrap\"><div class=\"mark\">“</div><blockquote>We replaced a designer-in-the-loop process with one API call. The cards went out the same afternoon the quotes came in.</blockquote><div class=\"who\"><div class=\"avatar\">PR</div><div><div class=\"name\">Priya Raman</div><div class=\"role\">Head of Product, Fieldline</div></div><div class=\"brand\">Fieldline · Customer stories</div></div></div></body></html>"
}
JSON That produced this, a native 1200x630 PNG at 192 KB:

The markup is ordinary CSS. Google Fonts load through @import, so a serif for the quote and a sans for the attribution costs one line. The .wrap is a flex column with justify-content: space-between, which pins the quotation mark to the top and the attribution to the bottom whatever length the quote in the middle turns out to be. The monogram avatar is a div with initials, which sidesteps fetching a headshot and is what many brands prefer for customer quotes anyway.
Two things to carry over from the template route. First, overflow: hidden on the body and an explicit width and height matching the request, so a long quote cannot push the attribution below the canvas. Second, the rendered image is a real browser screenshot, so anything you can express in CSS you can put on the card: a background photo with a dark overlay, a gradient border, a second column for a product screenshot.
Sizing the type to the quote
The template scales the font for you. In your own markup you have to do it, and the simplest way is to pick the size on the server before you send the HTML rather than trying to do it in CSS:
function quoteFontSize(text) {
const words = text.trim().split(/\s+/).length;
if (words <= 12) return 60;
if (words <= 25) return 48;
if (words <= 40) return 40;
return 34;
} Drop the result into an inline style on the blockquote and a 12-word quote renders at 60px while a 40-word one renders at 40px, both filling the middle of the card. Four bands are enough; the eye does not notice the steps, only whether the card looks full. If you would rather not maintain the bands, a small inline <script> that shrinks the font until the blockquote fits its box also works, because the renderer executes inline JavaScript before capture. Pair it with a short ms_delay so the screenshot waits for the loop to finish.
Rendering the whole backlog
One card is a curl. Forty is a loop, and the loop is where the time saving actually lives. Given a testimonials.json with a quote, a name and a role per entry:
import { readFile, writeFile } from 'node:fs/promises';
const testimonials = JSON.parse(await readFile('testimonials.json', 'utf8'));
for (const t of testimonials) {
const response = await fetch('https://app.html2img.com/api/v1/templates/quote-card', {
method: 'POST',
headers: {
'X-API-Key': process.env.HTML2IMG_API_KEY,
'Content-Type': 'application/json',
},
body: JSON.stringify({
quote: t.quote,
attribution_name: t.name,
attribution_role: t.role,
brand_name: 'Fieldline',
background_color: '#FAF7F0',
accent_color: '#2563EB',
}),
});
const { url } = await response.json();
t.card_url = url;
console.log(`${t.name}: ${url}`);
}
await writeFile('testimonials.json', JSON.stringify(testimonials, null, 2)); The script writes each card's URL back onto the testimonial record, so the JSON file becomes the source of truth for the marketing site, the case study PDF and the social scheduler at once. The same loop in PHP, Python or Ruby is a dozen lines through the SDKs and integration guides; if the quotes already live in a Laravel app, the Laravel integration is a facade call inside the same foreach.
A few notes for a batch of any size:
Each render is one credit, and
credits_remainingcomes back in every response so the loop can stop cleanly before a 402.GET /api/mereports the balance without spending one.For a batch in the hundreds, pass a
webhook_urlwith each request and let the API post the URL back to you rather than holding forty HTTP connections open in sequence.The CDN URL is stable for as long as the render exists, so it is safe to write into a CMS field or a newsletter. For cards going into an email, keep the file light by rendering through the HTML endpoint at the default
dpiof 1; the images-in-email guide covers what each client does with a hosted PNG.
One quote, three formats
Most teams need the square for the feed and the 1200x630 for the link preview, and occasionally the 1080x1920 for a Story. Rather than three templates, treat the format as a parameter: render the square through the template endpoint, and send the same quote and attribution through the HTML endpoint with width and height set for the other two, using the markup above with the body dimensions changed. For the Story format, add top and bottom padding of around 250px so the quote and attribution sit in the middle band where neither the progress bar nor the reply box will cover them.
The tweet-to-image workflow follows the same shape when the quote is a post rather than a testimonial, and the LinkedIn post template is the companion when the card is the post itself rather than a quote inside it.
Need testimonial cards, pull quotes or podcast highlights rendered from JSON without opening a design tool? Browse the templates gallery or read the docs to get started.
Written by
Mike Griffiths
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.