๐ŸŽ‰ Premium Proxies ยท 3-Day Free TrialClaim Now โ†’
Guides

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.

S SparkProxy 12 17 min read
Share
How to Scrape Pinterest Data: Pins, Boards, Search

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.

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.

FieldJSON path (per pin object)Example valueNotes
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.


Free trial

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:

GoalResource name`source_url` exampleKey `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:

  1. The first request omits bookmarks.
  2. Every response returns resource_response.bookmarks, a single-element array like ["Y2J2Nzk4..."].
  3. You send that string back in the next request's options.bookmarks, again as a one-element array.
  4. 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.

RequirementWhy it existsHow to satisfy it
Realistic `User-Agent`The default `python-requests/2.x` UA is blocklistedSend a current desktop Chrome UA string
`csrftoken` cookieResource endpoints reject sessionless callersGET `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 makesStatic header on every resource request
`x-app-version` / `x-pinterest-pws-handler`Build hash and route tag the real client sendsCopy from `__PWS_DATA__` or the Network tab
IP diversityPer-IP rate limits, then a login wall after N requestsRotate 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

SymptomCauseFix
`403 Forbidden` on the first callDefault UA or no `csrftoken` cookieSet a real Chrome UA and warm the session first
`KeyError: 'results'` on searchRead `data` instead of `data.results`Search nests pins under `data["results"]`
Parser returns zero pinsIncluded story/module objectsFilter on `p.get("type") == "pin"`
Pagination never stopsCursor sent as a string, `-end-` not checkedSend `[cursor]`; break when it equals `-end-`
`saves` is `None`Field absent when a pin has no savesDefault to `repin_count` or `0`
Empty results after ~100 requestsPer-IP rate limit or login wallRotate proxies and slow down
`429 Too Many Requests`Too many calls from one IPBack 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.


Limited-time ยท 50% off

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

Claim Discount

About the Author

This guide was written by the SparkProxy Technical Team. SparkProxy builds proxy and data-collection infrastructure used by scraping and automation teams worldwide: datacenter proxies, residential proxies, and a managed Scraping API that handles IP rotation, headers, and retries for you. We publish hands-on engineering guides grounded in the same endpoints, error strings, and rate limits our customers hit in production. Every code sample here was written to run against Pinterest's public data with the fields and pagination documented above.

Keep reading

Related articles