Facebook Page Posts API in Python: Cursor Pagination after a Page URL
What you usually have is a public Page URL, not a page_id. pages-posts will not take facebook.com/nasa. It also will not take page=2. After page-details turns that URL into an id, continuation is an opaque cursor the previous response minted — copy it, do not increment it. The bug is treating a Facebook Page like a numbered catalog, or skipping the URL step and stuffing a handle into page_id.
This guide is one Python loop that resolves the URL, copies the cursor verbatim, caps how many pages you will buy, stops when the cursor is missing or repeats, and deduplicates post_id when windows overlap. Opening the page or copying the snippets does not call anything; a request happens only when you replace the placeholders and run the code.
The Facebook Data API reads publicly visible Page surfaces over GET. It is not Meta Page Insights, not a private-content product, and not a complete archive. Public counts are collection-time snapshots.
Offset is not how Page posts continue
pages-posts has no page, no offset, and no client-chosen page length. Continuation is a sealed cursor on the same operation, for the same page_id. Do not parse it, truncate it, increment it, or paste a cursor from search-pages or search-post into this route. Adjacent pages can overlap. Page size is whatever results actually contains — treat that as an observation, not a contract. Reaching a stopping point does not mean you now hold the Page's full history.
Copy an authorized key from the dashboard after you sign in. Keep it in OPENDATA_API_KEY on the server. Query URLs include key, so redact them in logs, traces and screenshots; do not embed the value in a frontend bundle. A pasted URL may show YOUR_API_KEY; that string is a dead placeholder, not a live credential. No Facebook login cookie is involved.
Every call has two status layers. HTTP 4xx/5xx is transport, auth or quota. HTTP 200 is not success by itself: the gateway envelope still carries code, message and data, and business success is code=200. Then inspect the operation payload inside data. Page details uses a results object; Page posts uses a results array plus cursor. Do not recursively unwrap arbitrary objects, and do not rewrite a missing array as empty success.
A populated first page does not validate continuation, optional date filters, or every catalog route around this job. start_date and end_date are documented on some reads; their exact formats remain unvalidated — do not copy another product's date syntax.
Resolve the public URL first
A handle in a spreadsheet is not a page_id. page-details takes the public URL. The documented example is https://www.facebook.com/facebook. Inspect results.name and results.page_id before you collect. Search hits from search-pages are candidates, not official brand accounts, and they label the identifier facebook_id — not page_id. Confirm identity on details, then send the details page_id to posts.
A private_page result without identity is inconclusive: it cannot distinguish an absent Page from a private or inaccessible one. Stop. Do not invent an ID, and do not retry that shape as if the Page were flaky.
Keyword matches from search-post are not “this Page's authored posts.” An empty search-pages payload with results=[] and cursor=null can be a legitimate no-match. Check HTTP status and gateway code first; neither an empty search nor an ambiguous details object proves the Page does not exist anywhere.
First pages-posts request: page_id only. Omit cursor — do not send an empty string to “hold the slot.” Posts live in data.results; continuation sits on that same payload as cursor. Feed each returned value into the next request unchanged until the field is missing, null, or empty. A sampled details ID such as 100064860875397 can illustrate a follow-up in your notes; use the ID your details call actually returned.
Keep the cursor, cap the pages
Install requests and export OPENDATA_API_KEY on the server before you run anything. The loop below is a client-side budget (MAX_PAGES = 5), not a published service limit or retry contract. It stops on a missing, null, empty, or repeated cursor, or when the cap is hit. The in-memory set deduplicates within one run; persist (page_id, post_id) if you poll again later.
import json
import os
from datetime import datetime, timezone
import requests
API_KEY = os.environ["OPENDATA_API_KEY"]
BASE = "https://api.opendata-api.com/api/project/facebook-scraper"
MAX_PAGES = 5 # local cost guard, not an API limit
def read(operation, **params):
response = requests.get(
f"{BASE}/{operation}",
params={"key": API_KEY, **params},
timeout=30,
)
if not 200 <= response.status_code < 300:
raise RuntimeError(f"HTTP failure: {response.status_code}")
body = response.json()
if not isinstance(body, dict) or body.get("code") != 200:
raise RuntimeError("Gateway business failure; inspect redacted status")
payload = body.get("data")
if not isinstance(payload, dict):
raise RuntimeError("Unexpected operation payload; validate before parsing")
return payload
def collect(page_url):
details = read("page-details", url=page_url).get("results")
if not isinstance(details, dict) or not details.get("page_id"):
raise RuntimeError("Page identity unresolved; review the source URL")
if details.get("type") == "private_page":
raise RuntimeError("Inconclusive Page result; do not invent a page_id")
page_id = str(details["page_id"])
seen_posts, seen_cursors = set(), set()
cursor = None
for _ in range(MAX_PAGES):
params = {"page_id": page_id}
if cursor is not None:
params["cursor"] = cursor
payload = read("pages-posts", **params)
posts = payload.get("results")
if not isinstance(posts, list):
raise RuntimeError("Expected the sampled Page-post results array")
observed_at = datetime.now(timezone.utc).isoformat()
for post in posts:
if not isinstance(post, dict) or not post.get("post_id"):
raise RuntimeError("Post has no usable identifier")
post_id = str(post["post_id"])
if post_id in seen_posts:
continue
seen_posts.add(post_id)
timestamp = post.get("timestamp")
published_at = (
datetime.fromtimestamp(timestamp, timezone.utc).isoformat()
if isinstance(timestamp, (int, float))
else None
)
yield {
"page_id": page_id,
"post_id": post_id,
"published_at_utc": published_at,
"observed_at_utc": observed_at,
"raw": post,
}
next_cursor = payload.get("cursor")
if next_cursor is None or next_cursor == "":
break
if not isinstance(next_cursor, str):
raise RuntimeError("Unexpected cursor type")
if next_cursor in seen_cursors:
break
seen_cursors.add(next_cursor)
cursor = next_cursor
if __name__ == "__main__":
with open("page_posts.jsonl", "a", encoding="utf-8") as output:
for record in collect("https://www.facebook.com/facebook"):
output.write(json.dumps(record, ensure_ascii=False) + "\n")
Keep later counter observations in a timestamped table rather than overwriting a known post. Do not estimate inventory as “page size × pages.” Merge by string post_id. A failed run can leave a partial JSONL file; do not label it complete. No automatic retry is assumed here — if HTTP or gateway code fails, stop and inspect a redacted status rather than walking the cursor blindly.
Metrics you must not invent
Preserve what the post actually returned: post_id, url, message / message_rich, numeric timestamp, author / authors, comments_count, reactions_count, reshare_count, reaction-category counts, and nullable image / video / video_files. Keep IDs as strings. Missing is not zero. top_comments is a preview, not the full thread; comments_id and shares_id are downstream keys, not proof you already collected discussion.
Public follower and engagement numbers are observations, not impressions, reach, or Page Insights. Distinguish publication time from collection time when you chart posting cadence. Media references are not reuse rights, and they can change. Do not flatten every post into a homemade “media object,” and do not treat reaction-category counts as a sentiment model.
Public visibility does not itself authorize automated collection. Confirm terms, lawful processing, and media rights; minimize personal data; set retention. Stored URLs are not a license to download or redistribute.
The mistakes that still show up after the script compiles: sending page=2 / offset / a homemade cursor; decoding or shortening cursor; sharing one cursor across pages-posts and a search route; skipping page-details and typing a vanity URL into page_id; treating private_page as a verified miss; treating HTTP 200 as a post list when gateway code is not 200; calling a missing results array empty success; assuming a stop means complete history; overwriting engagement snapshots instead of stamping observed_at_utc; reading counts as Insights; logging request URLs that still contain the key. Stop on those before adding keyword search, ads, or comments — those routes paginate (or don't) under their own field names, and mixing them with this cursor is the same page=2 bug in a different costume.
See page-details and pages-posts. Per-request cost is on pricing.