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:readandpins:writescopes. 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:
| Column | Used for |
|---|---|
Title | Pin title. Pinterest allows up to 100 characters. |
Media URL | Public image URL, ideally 1000 × 1500 (2:3), the size Pinterest recommends. |
Pinterest board | Board name. The script maps it to a board ID. |
Description | Pin description. The script trims it to 500 characters, the CSV uploader's limit. |
Link | Destination URL. It must carry a unique utm_campaign (see dedup below). |
Publish date | The 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
| Call | Why |
|---|---|
GET /v5/boards | Turn board names into board IDs. |
GET /v5/pins | List what's already on the account, for dedup. Paginated: pass the bookmark from each response to get the next page. |
POST /v5/pins | Create 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_writecategory: 300 per day on Trial and 100 per minute on Standard. Listing pins and boards isorg_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.