How to Scrape Pinterest Data: Pins, Boards, Search
Learn how to scrape Pinterest data ethically: pull pins, boards, saves, and search results from Pinterest's JSON resource endpoints with working Python code.

To scrape Pinterest at any real scale, you do not parse rendered HTML or babysit a headless browser through infinite scroll. Every pin is already structured data: a title, an image, a description, a destination link, a save count, and the board and creator behind it. Pinterest's own frontend fetches that data from JSON resource endpoints, and you can call the same ones directly, page through them with bookmark cursors, and read clean objects back. This guide gives you the exact endpoints, the field map, pagination, the anti-bot headers, and working Python, all against public data and inside Pinterest's terms.
Is Scraping Pinterest Legal and Ethical?
Scraping public Pinterest data sits in a defensible legal zone, but "public" does the heavy lifting in that sentence. Stay on the right side of it and you avoid most of the risk.
The ground rules that keep a Pinterest scraper ethical and low-risk:
- Public only. Scrape pins, boards, and search results that any logged-out visitor can see. Never touch private boards, secret boards, or anything gated behind a login. Do not create fake accounts to reach protected content.
- Honor robots.txt and the ToS. Read
https://www.pinterest.com/robots.txtand respect itsDisallowrules. Pinterest's Terms of Service also restrict automated collection, so accessibility is not the same as permission. For commercial or high-volume work, treat the official Pinterest API v5 (https://api.pinterest.com/v5, OAuth 2.0 Bearer) as the first option. It cleanly covers your own account, boards, and approved partner data. It does not, however, expose arbitrary public search or other creators' pins at scale, which is why the web endpoints below exist. - Personal data is regulated. Creator usernames, display names, and profile images can be personal data under GDPR and the CCPA. Have a lawful basis, collect the minimum you need, and do not build behavioral profiles of individuals.
- Be a polite client. Rate-limit yourself, cache responses, and never hammer the site. A scraper that reads a few pages a minute looks nothing like one firing hundreds of parallel requests.
On the case law: in hiQ Labs v. LinkedIn (9th Cir., 2022) the court held that scraping publicly available data does not by itself violate the US Computer Fraud and Abuse Act. That ruling is about unauthorized access, not a blanket license. Breach-of-contract claims over the ToS and copyright over the images themselves still apply. None of this is legal advice; if you are collecting at scale or commercializing the output, talk to a lawyer.
What Data You Can Scrape from Pinterest
Each pin object Pinterest returns is a nested JSON document. These are the fields worth pulling and where they live in the resource response, so you can map straight to a database row.
| Field | JSON path (per pin object) | Example value | Notes |
|---|---|---|---|
| Pin ID | `id` | `"1069756909083871234"` | Stable, use as the primary key |
| Title | `grid_title` (feeds) or `title` | `"Minimalist Oak Shelf"` | `grid_title` on grids, `title` on the detail view |
| Description | `description` | `"Floating shelf build..."` | Often empty on image-only pins |
| Image | `images["orig"]["url"]` | `https://i.pinimg.com/originals/...jpg` | Also `736x`, `474x`, `236x` variants |
| Board | `board.name`, `board.url`, `board.id` | `"Home Ideas"` | The board the pin sits on |
| Saves | `aggregated_pin_data.aggregated_stats.saves` | `4210` | Falls back to `repin_count` |
| Link | `link` | `https://www.sparkproxy.io/blog` | Outbound destination the pin points to |
| Creator | `native_creator.username` or `pinner.username` | `"studio_maker"` | `full_name`, `id` sit alongside |
| Created | `created_at` | `"Tue, 01 Jul 2025 09:14:22 +0000"` | RFC 1123 string |
| Color | `dominant_color` | `"#c0392b"` | Handy for palette and dedupe work |
Two fields trip people up. The save count lives under aggregated_pin_data.aggregated_stats.saves, not at the top level, and it is absent when a pin has zero saves, so default it to repin_count or 0. The creator can arrive as native_creator, pinner, or origin_pinner depending on the endpoint, so check them in order.
Scraping at scale? Skip the blocks.
Fast, unblockable datacentre proxies with unlimited bandwidth.
How Pinterest Serves Its Data
Pinterest's web app is a single-page React frontend that talks to an internal RPC layer. Every list you see (a board, a search, a profile) is one HTTP call to a named resource. The pattern is consistent:
https://www.pinterest.com/resource/<ResourceName>/get/?source_url=<path>&data=<url-encoded-json>
The data parameter is a JSON blob with two keys, options (the query) and context (usually empty). The response wraps everything in resource_response. Learn these resources and you can scrape almost anything public:
| Goal | Resource name | `source_url` example | Key `options` |
|---|---|---|---|
| Pins in a board | `BoardFeedResource` | `/username/board-slug/` | `board_id`, `board_url`, `page_size`, `bookmarks` |
| Search pins | `BaseSearchResource` | `/search/pins/?q=oak+shelf` | `query`, `scope: "pins"`, `bookmarks` |
| A single pin | `PinResource` | `/pin/ | `id`, `field_set_key` |
| Creator profile | `UserResource` | `/username/` | `username` |
| A creator's pins | `UserActivityPinsResource` | `/username/` | `user_id` or `username`, `bookmarks` |
| Related pins | `RelatedModulesResource` | `/pin/ | `pin_id`, `bookmarks` |
There is a second, even simpler source. When you request a pin or board page as plain HTML, Pinterest server-renders the first screen of data into a script tag:
<script id="__PWS_DATA__" type="application/json">{"props":{"initialReduxState":{ ... }}}</script>
That JSON holds props.initialReduxState.pins, .boards, and more. For a single pin or the first page of a board, you can parse __PWS_DATA__ and skip the resource call entirely. Most guides miss this and pay for a browser render they never needed.
Scrape a Pinterest Board
Start with BoardFeedResource. You need the board's numeric board_id and its path (board_url). Both are in the board page's __PWS_DATA__, or you can read board_id from the first response. Here is a self-contained fetcher.
import json
import time
import requests
BASE = "https://www.pinterest.com/resource/BoardFeedResource/get/"
HEADERS = {
"User-Agent": (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36"
),
"Accept": "application/json, text/javascript, */*, q=0.01",
"X-Requested-With": "XMLHttpRequest",
"Referer": "https://www.pinterest.com/",
}
def board_page(session, board_id, board_url, bookmark=None):
options = {
"board_id": board_id,
"board_url": board_url,
"page_size": 25,
"prepend": False,
}
if bookmark:
options["bookmarks"] = [bookmark]
params = {
"source_url": board_url,
"data": json.dumps({"options": options, "context": {}}, separators=(",", ":")),
"_": int(time.time() * 1000), # cache buster the frontend sends
}
r = session.get(BASE, headers=HEADERS, params=params, timeout=30)
r.raise_for_status()
body = r.json()["resource_response"]
next_bookmark = (body.get("bookmarks") or ["-end-"])[0]
return body["data"], next_bookmark
The data JSON must be compact (separators=(",", ":")) and URL-encoded, which requests handles when you pass it through params. Now the field extraction, defensive about the fields that go missing:
def parse_pin(p):
stats = (p.get("aggregated_pin_data") or {}).get("aggregated_stats") or {}
creator = p.get("native_creator") or p.get("pinner") or {}
images = p.get("images") or {}
board = p.get("board") or {}
return {
"id": p.get("id"),
"title": p.get("grid_title") or p.get("title"),
"description": p.get("description"),
"image": (images.get("orig") or {}).get("url"),
"board": board.get("name"),
"board_url": board.get("url"),
"saves": stats.get("saves") or p.get("repin_count") or 0,
"link": p.get("link"),
"creator": creator.get("username"),
"created_at": p.get("created_at"),
}
The feed mixes real pins with story and recommendation modules, so filter on type == "pin" before you parse:
def scrape_board(board_id, board_url, max_pages=40):
session = requests.Session()
pins, bookmark = [], None
for _ in range(max_pages):
data, bookmark = board_page(session, board_id, board_url, bookmark)
pins += [parse_pin(p) for p in data if p.get("type") == "pin"]
if not bookmark or bookmark == "-end-":
break
time.sleep(1.5) # stay polite
return pins
The -end- sentinel is the whole game for pagination. More on that below.
Scrape Pinterest Search Results
Search runs through BaseSearchResource. The only real change from the board fetcher is the resource name, the options, and one response-shape gotcha: search puts its pins under data.results, while a board feed puts them directly under data. Miss that and your parser returns nothing.
import urllib.parse
SEARCH = "https://www.pinterest.com/resource/BaseSearchResource/get/"
def search_page(session, query, bookmark=None):
options = {"query": query, "scope": "pins", "page_size": 25}
if bookmark:
options["bookmarks"] = [bookmark]
params = {
"source_url": f"/search/pins/?q={urllib.parse.quote(query)}",
"data": json.dumps({"options": options, "context": {}}, separators=(",", ":")),
"_": int(time.time() * 1000),
}
r = session.get(SEARCH, headers=HEADERS, params=params, timeout=30)
r.raise_for_status()
body = r.json()["resource_response"]
results = body["data"]["results"] # note: .results, not .data
return results, (body.get("bookmarks") or ["-end-"])[0]
def scrape_search(query, max_pages=20):
session = requests.Session()
pins, bookmark = [], None
for _ in range(max_pages):
results, bookmark = search_page(session, query, bookmark)
pins += [parse_pin(p) for p in results if p.get("type") == "pin"]
if not bookmark or bookmark == "-end-":
break
time.sleep(2)
return pins
This is how you scrape Pinterest pins for a keyword, a hashtag, or a product line. Set scope to "boards" to pull boards instead, or "users" for creators. Search is where rate limits bite first, so keep page_size at 25 and space requests out.
Scrape a Single Pin and Its Creator
For one pin, the HTML route is cheaper than a resource call because the data is already inlined. Parse __PWS_DATA__ and pull the pin straight out of the Redux state.
from bs4 import BeautifulSoup
def scrape_pin_html(session, pin_id):
url = f"https://www.pinterest.com/pin/{pin_id}/"
html = session.get(url, headers=HEADERS, timeout=30).text
soup = BeautifulSoup(html, "html.parser")
blob = soup.find("script", id="__PWS_DATA__")
if not blob:
return None
state = json.loads(blob.string)["props"]["initialReduxState"]
pin = (state.get("pins") or {}).get(str(pin_id))
return parse_pin(pin) if pin else None
To go from a pin to the person who posted it, take the creator username and call UserResource, which returns follower counts, pin counts, and the profile fields. If you plan to track those creators over time, the workflow in using proxies for social media monitoring maps almost directly onto Pinterest: rotate IPs per account, snapshot on a schedule, and diff the numbers.
Paginate with Bookmarks
Pinterest paginates with an opaque cursor it calls a bookmark, not a page number or offset. The contract is simple once you see it:
- The first request omits
bookmarks. - Every response returns
resource_response.bookmarks, a single-element array like["Y2J2Nzk4..."]. - You send that string back in the next request's
options.bookmarks, again as a one-element array. - When the array comes back as
["-end-"], you have reached the last page. Stop.
That -end- string is a real value, not an empty result, so test for it explicitly. The mistake that causes silent infinite loops is passing the cursor as a bare string instead of a list; options["bookmarks"] must always be [cursor]. A reusable driver looks like this:
def paginate(fetch_fn, max_pages=50, delay=1.5):
"""fetch_fn(bookmark) -> (items, next_bookmark)"""
items, bookmark = [], None
for _ in range(max_pages):
page, bookmark = fetch_fn(bookmark)
items += page
if not bookmark or bookmark == "-end-":
break
time.sleep(delay)
return items
Cap max_pages. A broad search can page for a very long time, and you rarely need everything past the first few hundred results.
Get Past Pinterest's Anti-Bot Defenses
A default python-requests call to a resource endpoint gets a 403 almost immediately. Pinterest fingerprints the request, not just the IP. Here is what actually matters and how to satisfy it.
| Requirement | Why it exists | How to satisfy it |
|---|---|---|
| Realistic `User-Agent` | The default `python-requests/2.x` UA is blocklisted | Send a current desktop Chrome UA string |
| `csrftoken` cookie | Resource endpoints reject sessionless callers | GET `pinterest.com` once, reuse the `Set-Cookie` token across the session |
| `X-Requested-With: XMLHttpRequest` | Marks the call as the in-app XHR the frontend makes | Static header on every resource request |
| `x-app-version` / `x-pinterest-pws-handler` | Build hash and route tag the real client sends | Copy from `__PWS_DATA__` or the Network tab |
| IP diversity | Per-IP rate limits, then a login wall after N requests | Rotate residential or datacenter proxies |
Grab the CSRF cookie before you start by hitting the homepage with the same Session:
def warm_session():
s = requests.Session()
s.get("https://www.pinterest.com/", headers=HEADERS, timeout=30)
return s # now carries the csrftoken cookie for later resource calls
The header work gets you started; IP reputation is what keeps you running. Pinterest counts requests per IP and starts serving login walls or empty results well before you finish a large job. Rotating through a proxy pool spreads the load so no single address crosses the threshold. Datacenter IPs are fast and cheap for the JSON endpoints, which is exactly the sweet spot covered in using datacenter proxies for web scraping; switch to residential when a target region gates harder. Either way, the tactics in how to avoid getting your proxy blocked apply one-for-one here: rotate on failure, back off on 429, and never reuse a burned IP straight away.
Scrape Pinterest with the SparkProxy Scraping API
Running your own proxy pool, CSRF warm-up, and retry loop is the DIY path, and it works. When you would rather not maintain any of it, the SparkProxy Scraping API handles IP rotation, headers, and retries server-side. You hand it the Pinterest resource URL and it returns the JSON.
import json
import urllib.parse
import requests
def sparkproxy_search(query, api_key, bookmark=None):
options = {"query": query, "scope": "pins", "page_size": 25}
if bookmark:
options["bookmarks"] = [bookmark]
data = json.dumps({"options": options, "context": {}}, separators=(",", ":"))
target = (
"https://www.pinterest.com/resource/BaseSearchResource/get/"
f"?source_url=/search/pins/?q={urllib.parse.quote(query)}"
f"&data={urllib.parse.quote(data)}"
)
r = requests.get(
"https://scrape.sparkproxy.io/api/v1",
headers={"X-API-Key": api_key},
params={
"url": target,
"render_js": "false", # JSON endpoint, no browser needed (1 credit)
"premium_proxy": "true", # residential exit for tougher regions
"country_code": "us", # geo-target the exit IP
},
timeout=60,
)
r.raise_for_status()
body = r.json()["resource_response"]
return body["data"]["results"], (body.get("bookmarks") or ["-end-"])[0]
Authentication is the X-API-Key header. Because the resource endpoints return JSON, keep render_js off at 1 credit per call and reserve rendering for pages where you actually need __PWS_DATA__ from server-side HTML. premium_proxy and country_code matter when Pinterest geo-restricts or blocks a datacenter range. If you are weighing this managed route against building the pool yourself, web scraping API vs self-managed proxies breaks down the cost and control trade-off in detail. A practical split: self-managed pool for high-volume board crawls where per-request cost dominates, and the Scraping API for search and any endpoint that starts fighting back.
Common Errors and Fixes
| Symptom | Cause | Fix |
|---|---|---|
| `403 Forbidden` on the first call | Default UA or no `csrftoken` cookie | Set a real Chrome UA and warm the session first |
| `KeyError: 'results'` on search | Read `data` instead of `data.results` | Search nests pins under `data["results"]` |
| Parser returns zero pins | Included story/module objects | Filter on `p.get("type") == "pin"` |
| Pagination never stops | Cursor sent as a string, `-end-` not checked | Send `[cursor]`; break when it equals `-end-` |
| `saves` is `None` | Field absent when a pin has no saves | Default to `repin_count` or `0` |
| Empty results after ~100 requests | Per-IP rate limit or login wall | Rotate proxies and slow down |
| `429 Too Many Requests` | Too many calls from one IP | Back off, add jitter, rotate the exit IP |
Frequently asked questions
FAQ
Scraping publicly visible Pinterest data is generally lawful in the US: in hiQ v. LinkedIn (2022) the Ninth Circuit held that scraping public data does not violate the CFAA. That is not a free pass. Pinterest's Terms of Service restrict automated collection, image copyright still applies, and creator data can be personal data under GDPR or CCPA. Stay on public content, respect robots.txt, and get legal advice before commercializing.
No, not for public data. The official Pinterest API v5 (https://api.pinterest.com/v5, OAuth 2.0) is built for your own account, your boards, and approved partner access. It does not expose arbitrary public search or other creators' pins at scale, which is why a Pinterest scraper reads the same JSON resource endpoints the website's frontend calls.
Call BaseSearchResource with a data payload of {"options": {"query": "your term", "scope": "pins", "page_size": 25}}, URL-encoded. The pins come back under resource_response.data.results, and resource_response.bookmarks gives you the cursor for the next page. Loop until the bookmark returns -end-.
The bookmark is Pinterest's opaque pagination cursor, returned as a one-element array in every response. You pass the previous response's bookmark into the next request's options.bookmarks (always as a list) to get the following page. When Pinterest returns ["-end-"], there are no more results.
Almost always the request fingerprint. The default python-requests User-Agent is blocklisted, and the resource endpoints reject callers without a csrftoken cookie. Send a current desktop Chrome UA, warm a requests.Session against the homepage to pick up the cookie, add X-Requested-With: XMLHttpRequest, and rotate your IP once volume climbs.
Yes. Each pin's images object holds several sizes; images["orig"]["url"] is the original upload, with 736x, 474x, and 236x as smaller variants. Download the orig URL for full resolution, but remember the images are copyrighted by their creators, so respect usage rights before republishing.
Get 50% off your first purchase
Premium datacentre proxies with unlimited bandwidth. Use the code at checkout.
Offer ends soon โ claim it before it's gone
Related articles

How to Handle Cookies and Sessions When Web Scraping
Handle cookies and sessions when web scraping: cookie jars, Set-Cookie parsing, CSRF tokens, login state, disk persistence, and sticky proxy IPs, in Python.

Async Web Scraping in Python: A Concurrency Guide
Master async web scraping in Python with asyncio, httpx and aiohttp: bound concurrency with semaphores, size connection pools, and isolate failures.

How to Scrape Google News: RSS, Headlines, and Feeds
Scrape Google News with its free RSS feeds: pull headlines and articles, decode the redirected article links, and geo-target editions with hl, gl, and ceid.
