Tutorials

How to Generate Pinterest Pins from an API for Every Blog Post, Recipe and Product

How to Generate Pinterest Pins from an API for Every Blog Post, Recipe and Product

A food blog with 300 recipes has 300 pins to make. A shop with 80 products in a Pinterest-friendly category has 80, plus a fresh set every time the catalogue changes. A travel site that publishes twice a week needs two more every week for as long as it publishes. Pinterest is the one social platform where that effort pays back for years, because pins behave like search results rather than feed posts, and it is still the platform where most teams design each pin by hand in Canva or skip it altogether.

This article shows how to render Pinterest pins from an API instead: one request per post, a 1000x1500 PNG back on a hosted URL, and a loop that covers the whole archive. Every image below is the actual output of the request above it.

What a Pinterest pin has to get right

Three things separate a pin that gets saved from one that gets scrolled past, and all three are decided before the request is sent.

The first is the ratio. Pinterest recommends 2:3 for standard pins, which is 1000x1500 at the size it documents, and the feed is built around that shape. Taller pins get cropped in the grid so the bottom of the design disappears, and a horizontal blog hero reused as a pin turns into a thin strip between two full-height competitors. The pinterest-pin template defaults to 1000x1500 for this reason, and if you render your own markup through the HTML endpoint you set the same dimensions yourself.

The second is the text. Pinterest is a search engine, so the pin surfaces next to a query, at thumbnail size, on a phone. The title has to be readable at roughly a third of its rendered size, which means a bold weight, a large size and a short line count. Four or five words per line and no more than three lines is the working rule. Supporting copy is fine as long as it stays clearly secondary.

The third is the image behind the text. Pinterest audiences respond to a photo, and a photo behind a title needs either a solid panel for the text or an overlay strong enough to hold contrast. This is the part that drifts when pins are made by hand, and the part a template fixes once.

The template route below handles the ratio and the layout 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 Pinterest Pin template takes one required input, title, and seven optional ones for a subtitle, an eyebrow label, the site name, a background photo, a logo and two colours:

curl -X POST https://app.html2img.com/api/v1/templates/pinterest-pin \
  -H "X-API-Key: $HTML2IMG_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "title": "The best day hikes in Iceland'\''s highlands",
    "subtitle": "Twelve routes from two-hour loops to a full-day ridge walk, sorted by difficulty.",
    "eyebrow": "TRAVEL GUIDE",
    "site_name": "northroads.co",
    "background_image_url": "https://picsum.photos/id/393/1000/1500",
    "background_color": "#F4F6F5",
    "accent_color": "#0F766E"
  }'

The response is the standard envelope with a CDN URL:

{
  "success": true,
  "id": "60cd7f89-e9f0-4d9a-8d83-16d949f61564",
  "template": "pinterest-pin",
  "expires_at": null,
  "credits_remaining": 100,
  "url": "https://i.html2img.com/image-1789406387141-373279.png"
}

And this is the file at that URL:

Rendered Pinterest pin: a 2:3 portrait card with a photo of green Icelandic highlands streaked with snow filling the top half, a small teal TRAVEL GUIDE label in the top-left corner, then on a pale grey panel below the photo the title The best day hikes in Iceland's highlands in large bold dark type, a short teal rule, a one-line subtitle in grey and the site name northroads.co in teal at the bottom

A few things the render does without being asked. The photo is masked into the top half and the title sits on a solid panel beneath it, so the contrast problem never arises: whatever the photo looks like, the text is dark on a plain background. The title has been sized to fill the panel across two lines, which is the weight that reads in a search grid. The accent_color shows up in the eyebrow pill, the rule under the title and the site name, and nowhere else, so one hex value keeps the pin on-brand without turning the whole card that colour.

The file came back at 2000x3000, because the template renders at 2x, and at 4.5 MB with the photo. That is well inside the 20 MB Pinterest allows for an image pin and sharp on a retina feed. If you need a lighter file, the HTML route below renders at 1x unless you ask for more.

Two inputs are left out above. logo_url replaces the text site name with your mark, and background_image_url can be dropped entirely, in which case the top half becomes a flat background_color panel, which suits a listicle or a quote pin better than a photo would. Both URLs are fetched at render time, so they need to be publicly reachable, and a product photo behind a CDN or a signed S3 URL both work. The Pinterest Pin template page has sample renders and use cases, the template reference lists every input with its default, and the Pinterest Pin 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 pin style, or a pin that needs elements the template does not have (a recipe stats row, a numbered listicle hook, a call-to-action button), needs its own markup. That is the HTML endpoint, and because you set the dimensions it is also how you get a 1000x2100 long pin if you ever want one:

curl -X POST https://app.html2img.com/api/html \
  -H "X-API-Key: $HTML2IMG_API_KEY" \
  -H "Content-Type: application/json" \
  -d @- <<'JSON'
{
  "width": 1000,
  "height": 1500,
  "html": "<!doctype html><html><head><meta charset=\"utf-8\"><style>@import url('https://fonts.googleapis.com/css2?family=Fraunces:opsz,[email protected],700&family=Manrope:wght@500;700;800&display=swap');*{box-sizing:border-box;margin:0}body{width:1000px;height:1500px;overflow:hidden;position:relative;background:#1C1917;color:#FAFAF9;font-family:Manrope,sans-serif}.glow{position:absolute;inset:0;background:radial-gradient(900px 700px at 50% 0%,rgba(234,88,12,.35),transparent 65%)}.wrap{position:absolute;inset:80px 72px;display:flex;flex-direction:column;justify-content:space-between}.eyebrow{font-weight:800;font-size:26px;letter-spacing:.22em;text-transform:uppercase;color:#FDBA74}.num{font-family:Fraunces,serif;font-size:420px;line-height:.8;color:#EA580C;margin-top:20px}h1{font-family:Fraunces,serif;font-weight:700;font-size:100px;line-height:1.02;letter-spacing:-.015em;max-width:820px;margin-top:24px}.sub{font-size:30px;line-height:1.4;color:rgba(250,250,249,.72);max-width:760px;margin-top:28px}.stats{display:flex;gap:16px}.stat{flex:1;border:2px solid rgba(250,250,249,.18);border-radius:18px;padding:22px 26px}.stat b{display:block;font-size:40px;font-weight:800}.stat span{display:block;font-size:20px;color:rgba(250,250,249,.6);margin-top:4px;text-transform:uppercase;letter-spacing:.12em}.foot{display:flex;justify-content:space-between;align-items:center;font-weight:700;font-size:28px}.foot em{font-style:normal;background:#EA580C;color:#fff;padding:14px 28px;border-radius:999px;font-size:24px}</style></head><body><div class=\"glow\"></div><div class=\"wrap\"><div><div class=\"eyebrow\">Sourdough basics</div><div class=\"num\">7</div><h1>mistakes that flatten your sourdough</h1><p class=\"sub\">Under-proofed dough, a cold oven and five other things that stop the loaf rising. Each one is a two-minute fix.</p></div><div class=\"stats\"><div class=\"stat\"><b>24h</b><span>Total time</span></div><div class=\"stat\"><b>1 loaf</b><span>Makes</span></div><div class=\"stat\"><b>Easy</b><span>Skill</span></div></div><div class=\"foot\"><span>hearthandpan.com</span><em>Read the guide</em></div></div></body></html>"
}
JSON

That produced this, a native 1000x1500 PNG at 294 KB:

Rendered custom Pinterest pin: a dark 2:3 card with a warm orange glow at the top, a small orange SOURDOUGH BASICS label, an enormous orange numeral 7 in a serif face, the title mistakes that flatten your sourdough in white serif type across two lines, a two-line grey subtitle, a row of three outlined stat boxes reading 24h total time, 1 loaf makes and Easy skill, and at the foot the site name hearthandpan.com on the left and an orange Read the guide button on the right

The markup is ordinary CSS. Fonts load through @import, so a display serif for the number and headline and a sans for everything else costs one line. The .wrap is a flex column with justify-content: space-between, which pins the header block to the top, the stats row to the middle and the site name and button to the bottom whatever length the title turns out to be. The button is a span with a border radius; it is not clickable, because a pin is a picture, but a button-shaped element tells the viewer the pin leads somewhere, which is why it turns up on so many high-performing pins.

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 title cannot push the footer below the canvas. Second, the render is a real browser screenshot, so a photo is a background-image with a gradient overlay on top, and a product pin is the same markup with the price in one of the stat boxes.

To match the template's 2x output, add "dpi": 2 to the request and the same markup comes back at 2000x3000. The DPI parameter is the only change; the CSS pixel dimensions stay at 1000x1500.

Pinning the hosted URL

The URL in the response is the whole handoff. Pinterest's API creates a pin from an image URL, fetches the file once and stores its own copy, so the CDN URL only has to be live at the moment you create the pin:

curl -X POST https://api.pinterest.com/v5/pins \
  -H "Authorization: Bearer $PINTEREST_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "board_id": "1042359123456789012",
    "link": "https://northroads.co/guides/iceland-highlands-day-hikes",
    "title": "The best day hikes in Iceland'\''s highlands",
    "description": "Twelve routes from two-hour loops to a full-day ridge walk, sorted by difficulty.",
    "media_source": {
      "source_type": "image_url",
      "url": "https://i.html2img.com/image-1789406387141-373279.png"
    }
  }'

The title and description on the pin are what Pinterest indexes for search, so send the same words you rendered into the image rather than leaving them blank. The link is the page the pin sends traffic to. If you schedule through Tailwind or a similar tool instead of the API directly, the same hosted URL goes in the image field.

One pin per post, at publish time

A single pin is a curl. The value is in the archive, and the archive is a loop. Given the posts as JSON, each with a title, a hook, a category, a hero image URL and a page URL:

import json
import os
import requests

RENDER = "https://app.html2img.com/api/v1/templates/pinterest-pin"
PIN = "https://api.pinterest.com/v5/pins"

with open("posts.json") as f:
    posts = json.load(f)

for post in posts:
    render = requests.post(
        RENDER,
        headers={"X-API-Key": os.environ["HTML2IMG_API_KEY"]},
        json={
            "title": post["title"],
            "subtitle": post["hook"],
            "eyebrow": post["category"].upper(),
            "site_name": "northroads.co",
            "background_image_url": post["hero_image"],
            "background_color": "#F4F6F5",
            "accent_color": "#0F766E",
        },
    ).json()

    pin = requests.post(
        PIN,
        headers={"Authorization": f"Bearer {os.environ['PINTEREST_TOKEN']}"},
        json={
            "board_id": post["board_id"],
            "link": post["url"],
            "title": post["title"],
            "description": post["hook"],
            "media_source": {"source_type": "image_url", "url": render["url"]},
        },
    ).json()

    post["pin_url"] = render["url"]
    post["pin_id"] = pin["id"]
    print(f'{post["title"]}: {pin["id"]} ({render["credits_remaining"]} credits left)')

with open("posts.json", "w") as f:
    json.dump(posts, f, indent=2)

The script writes the rendered URL and the Pinterest pin id back onto each post record, so the JSON file records what has been pinned and where, and running it again on new posts only touches the ones without a pin_id. The same loop in PHP, Node or Ruby is a dozen lines through the SDKs and integration guides. If the posts live in a Laravel app, the Laravel integration makes the render a facade call inside a PostPublished listener, so the pin exists a few seconds after the post does. On WordPress, the plugin puts the same API inside the admin without any of this code.

A few notes for an archive of any size:

  • Each render is one credit, and credits_remaining comes back in every response so the loop can stop cleanly before a 402.

  • For a backlog in the hundreds, pass a webhook_url with each render and let the API post the URL back to you, then create the pin in the webhook handler rather than holding the connection open.

  • Pinterest rewards fresh pins over repins of the same image, so the standard tactic is two or three pins per post with a different title, a different photo or a different accent_color. That is a second loop over the same records, not a second design job, which is the point of rendering rather than drawing.

The same pin at other sizes

Most sites need the 2:3 for Pinterest and nothing else, but a recipe or product that goes on Pinterest usually goes on Instagram Stories and into an Open Graph preview too. The Stories format is 1080x1920 and has the same "keep the text out of the top and bottom" discipline as the pin; the Instagram Story template handles it from the same inputs. The link preview is 1200x630 and the safe zone guide covers where each platform crops it. For product pins specifically, the Shopify product card article walks through pulling the catalogue and rendering one card per SKU; swap the endpoint for pinterest-pin and the loop is the same.


Need a pin for every post, recipe or product rendered from JSON without opening a design tool? 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