Skip to content
HackHourHQ

HH-0006 · Rev A ·

Bulk-publish Pinterest pins from a CSV with Python and the v5 API

Kip CroninPublished ~9 min read

To bulk-publish Pinterest pins from a CSV, read each row, skip any pin that's already live, and send the rest to POST /v5/pins with a board ID, title, description, link and image URL. Then cap how many go out per day, so a queue that stalled for a month can't dump everything at once.

This guide is the script we wish we'd had: about 110 lines of Python, built from the publisher we wrote for our own account. It reads the same CSV format as Pinterest's bulk uploader, dedups against what's already on your account, and refuses to post more than a few pins a day. You need an app with Standard access. On Trial access, Pinterest says pins you create are only visible to you as sandbox entities.

What you need

  • A Pinterest developer app with Standard access and an access token with the boards:read, pins:read and pins:write scopes. When we were on Trial in 2026, creating a pin in production failed with error code 29, "Apps with Trial access may not create Pins in production".
  • Python 3.10 or newer and requests (pip install requests).
  • Pin images at public URLs. Pinterest fetches the image from the URL itself; you don't upload bytes.
  • A CSV in the bulk-upload format below. The script handles the publishing; what goes in each row (titles people search for, a destination that keeps the pin's promise) is covered in the Pinterest Handbook and the blog traffic funnel.

The CSV: use Pinterest's own columns

Pinterest's web uploader takes a CSV with fixed column names, documented in Bulk upload Pins. We use the same columns, so one file works for both the API script and the web uploader:

ColumnUsed for
TitlePin title. Pinterest allows up to 100 characters.
Media URLPublic image URL, ideally 1000 × 1500 (2:3), the size Pinterest recommends.
Pinterest boardBoard name. The script maps it to a board ID.
DescriptionPin description. The script trims it to 500 characters, the CSV uploader's limit.
LinkDestination URL. It must carry a unique utm_campaign (see dedup below).
Publish dateThe day the pin becomes due. Rows dated in the future wait.

A row looks like this:

Title,Media URL,Pinterest board,Description,Link,Publish date
The Pinterest Handbook for Small Accounts,https://example.com/pins/pin-01.png,Pinterest Tips,"Vertical 2:3 pins, search-shaped titles...",https://example.com/pinterest/?utm_source=pinterest&utm_medium=pin&utm_campaign=pin-01,2026-10-12

The three endpoints

CallWhy
GET /v5/boardsTurn board names into board IDs.
GET /v5/pinsList what's already on the account, for dedup. Paginated: pass the bookmark from each response to get the next page.
POST /v5/pinsCreate the pin, with media_source.source_type set to image_url.

The request and response shapes are in Pinterest's API reference.

The dedup trick: key on utm_campaign

A local "already published" file isn't enough. We ran Pinterest's own CSV scheduler and our script side by side, and the script couldn't see pins the scheduler had posted. We also didn't find an API endpoint that lists scheduled pins, only published ones.

So every pin's link carries a unique utm_campaign, and that value is the pin's identity. Before posting, the script lists every pin on the account, pulls utm_campaign out of each link, and skips any CSV row whose campaign is already live. It doesn't matter whether the pin got there through the API, the web uploader or a scheduler. You also get per-pin attribution in your analytics for free.

The one rule: campaigns must be unique per pin. The script stops if a row has no utm_campaign rather than guess.

The guard: post at most N per day

This is the part we learned the hard way. Our publisher ran from a systemd timer with Persistent=true, which runs missed jobs on the next boot. It was written to post everything that was due, with no cap. Then it stopped posting, most likely because its token expired, and nothing told us. When we audited it in October 2026, 139 pins were overdue, and the first run after re-authorising would have posted all 139 about two seconds apart.

Pinterest's own guidance is to create pins "frequently and consistently". A 139-pin morning followed by silence is the opposite of that. So the script below counts what it already posted today and stops at MAX_PER_DAY, however long the queue is. A backlog drains over days instead of in one run.

The full script

Save it as publish_csv.py. It reads the token from the PINTEREST_ACCESS_TOKEN environment variable, so nothing secret lives in the file.

"""Publish due pins from a Pinterest bulk-upload CSV via the v5 API."""
import csv
import json
import os
import sys
import time
from datetime import date, datetime
from pathlib import Path
from urllib.parse import parse_qs, urlparse

import requests

API = os.environ.get("PINTEREST_API_BASE", "https://api.pinterest.com")
TOKEN = os.environ["PINTEREST_ACCESS_TOKEN"]
CSV_PATH = Path(sys.argv[1] if len(sys.argv) > 1 else "pins.csv")
LOG_PATH = Path("published.json")
MAX_PER_DAY = 3        # hard cap, whatever the queue says
DRY_RUN = "--dry-run" in sys.argv

session = requests.Session()
session.headers["Authorization"] = f"Bearer {TOKEN}"


def campaign_of(link: str) -> str | None:
    """The pin's dedup key: utm_campaign in its destination link."""
    values = parse_qs(urlparse(link).query).get("utm_campaign")
    return values[0] if values else None


def paged(path: str):
    """Yield every item from a v5 list endpoint, following the bookmark."""
    params = {"page_size": 100}
    while True:
        r = session.get(f"{API}{path}", params=params, timeout=30)
        r.raise_for_status()
        body = r.json()
        yield from body.get("items", [])
        if not body.get("bookmark"):
            return
        params["bookmark"] = body["bookmark"]


def live_campaigns() -> set[str]:
    """utm_campaign of every pin already on the account, however it got there."""
    return {c for pin in paged("/v5/pins") if (c := campaign_of(pin.get("link") or ""))}


def board_ids() -> dict[str, str]:
    return {b["name"]: b["id"] for b in paged("/v5/boards")}


def load_log() -> list[dict]:
    return json.loads(LOG_PATH.read_text()) if LOG_PATH.exists() else []


def main():
    today = date.today()
    log = load_log()
    done = {e["campaign"] for e in log if e["status"] == "ok"} | live_campaigns()
    sent_today = sum(1 for e in log if e["status"] == "ok" and e["ts"][:10] == today.isoformat())
    budget = MAX_PER_DAY - sent_today

    with CSV_PATH.open(encoding="utf-8", newline="") as f:
        rows = list(csv.DictReader(f))

    due = []
    for row in rows:
        key = campaign_of(row["Link"])
        if not key:
            sys.exit(f"Row without utm_campaign, refusing to guess: {row['Title']!r}")
        publish_on = (row.get("Publish date") or "")[:10]
        if key in done or not publish_on or date.fromisoformat(publish_on) > today:
            continue
        due.append((publish_on, key, row))
    due.sort(key=lambda d: d[0])

    print(f"{len(due)} due, {sent_today} already sent today, posting at most {max(budget, 0)}")
    if budget <= 0 or not due:
        return

    boards = board_ids()
    for publish_on, key, row in due[:budget]:
        payload = {
            "board_id": boards[row["Pinterest board"]],
            "title": row["Title"][:100],
            "description": row["Description"][:500],
            "link": row["Link"],
            "media_source": {"source_type": "image_url", "url": row["Media URL"]},
        }
        if DRY_RUN:
            print(f"[dry] {key} -> {row['Pinterest board']}")
            continue
        r = session.post(f"{API}/v5/pins", json=payload, timeout=30)
        entry = {"campaign": key, "ts": datetime.now().isoformat(timespec="seconds")}
        if r.ok:
            entry |= {"status": "ok", "pin_id": r.json()["id"]}
        else:
            entry |= {"status": "fail", "code": r.status_code, "error": r.text[:300]}
        log.append(entry)
        LOG_PATH.write_text(json.dumps(log, indent=2))  # save after every pin
        print(entry["status"], key, entry.get("pin_id") or entry.get("error"))
        if r.status_code in (401, 429):
            break  # expired token or rate limit: stop, don't hammer
        time.sleep(2)


if __name__ == "__main__":
    main()

Run it with a dry run first:

export PINTEREST_ACCESS_TOKEN=...   # from your OAuth flow; never commit it
python publish_csv.py pins.csv --dry-run
python publish_csv.py pins.csv

Each run writes published.json after every pin, so a crash halfway through doesn't lose track of what went out. A 401 (expired token) or 429 (rate limited) stops the run instead of failing on every remaining row.

Rate limits, from Pinterest's docs

Pinterest's rate limits page lists, as of October 2026:

  • Trial access: 1,000 requests per day per app across all endpoints.
  • Standard access: 100 requests per second per user per app.
  • Per-category limits on top. Creating pins falls in the org_write category: 300 per day on Trial and 100 per minute on Standard. Listing pins and boards is org_read: 1,000 per day on Trial and 1,000 per minute on Standard.

Responses carry x-ratelimit-limit, x-ratelimit-remaining and x-ratelimit-reset headers if you want to watch your budget. At three pins a day plus a few list calls, this script never comes close. Pinterest notes the limits can change, so check the page rather than trusting this paragraph.

Tokens expire; plan for it

The token response tells you when. Ours, in May 2026, had expires_in: 2592000 (30 days) for the access token and refresh_token_expires_in: 5184000 (60 days) for the refresh token. Our scheduled job had no refresh step, so the access token died after a month and the refresh token a month after that. Nothing alerted us.

If you run this on a schedule, refresh the token before each run with a POST /v5/oauth/token using grant_type=refresh_token (described in Pinterest's authentication docs, which also say to refresh within 30 days and to repeat it before 60 days to keep the refresh token valid), and make a 401 loud: send yourself an email or fail the job visibly.

Running it daily

Any scheduler works: cron, a systemd timer or GitHub Actions. With the daily cap in place, missed runs are harmless. Without it, a scheduler that catches up on missed runs is how you end up with a 139-pin morning.

Everything above is enough to build your own. The $29 Bulk Publisher below packages the parts this guide skips: a browser OAuth flow with a token-refresh command, board and pin listing scripts, and a JSON-plan publisher with local state. To be clear about what it doesn't have yet: v1 dedups only against its own published.json and has no daily cap, so add the utm_campaign check and MAX_PER_DAY from this guide if you run it on a schedule.

HackHour Weekly, every Wednesday

One email a week: the week's hack and what changed. Unsubscribe anytime.

What's in an issue

Built here: Bulk Publisher

OAuth + batch publishing + dedup state tracker for Pinterest API v5. Publish 100+ pins from a JSON plan. Requires Pinterest Standard tier access, and the listing says so.

$29 · Python · Pinterest API v5 · state tracking

Digital download from Gumroad. No refunds.

Questions

Can I publish pins with the Pinterest API on Trial access?

Not publicly. Pinterest's access-tier docs say pins created with Trial access are only visible to their creator as sandbox entities. When we were on Trial in 2026, a production create call failed with error code 29. You need Standard access to publish pins other people can see.

How many pins can I create per day with the Pinterest API?

Pinterest's rate-limits page lists pin creation under the org_write category: 300 requests per day on Trial and 100 per minute on Standard, as of October 2026. The API limit is rarely the constraint. Posting a handful of pins a day, consistently, is what Pinterest's own guidance recommends.

How do I avoid publishing the same pin twice?

Give every pin's link a unique utm_campaign, list the pins already on the account with GET /v5/pins (following the bookmark to page through), and skip any row whose utm_campaign is already live. That works even for pins posted by Pinterest's web uploader or another scheduler.

Do I upload the image file through the API?

Not for image pins. You pass media_source with source_type image_url and a public URL, and Pinterest fetches the image. Video pins use a separate media upload step.

How long does a Pinterest access token last?

Pinterest's authentication docs say to refresh the access token within 30 days of issue and to keep refreshing before 60 days so the refresh token stays valid. Our own token response in May 2026 had expires_in 2592000 and refresh_token_expires_in 5184000, which matches.

Can I schedule pins for a future date through the API?

This script doesn't: it publishes immediately and uses the CSV's Publish date only to decide when a row is due. That avoids Pinterest's limits on how many pins can sit in the scheduled queue.

Sources

Platform facts in this guide link to these pages. Last checked .

  1. Pinterest API v5 reference, Pinterest Developers
  2. Access tiers, Pinterest Developers
  3. Rate limits, Pinterest Developers
  4. Set up authentication and authorization, Pinterest Developers

Revision history

RevDateChange
AFirst published.

Errata

No corrections so far. Spotted one? Tell us through the site chat.