Get Perplexity citations in Python
Getting Perplexity's citations in Python takes one requests.post to https://api.agentgeo.org/v1/fetches with the query and "surfaces": ["perplexity"] — the response carries answers[0].sources[], each entry a title, a url and a position, no HTML parsing involved. Perplexity cites nearly every answer, so on this surface the work starts after the call: normalizing URLs so one page doesn't count as three, counting hosts across a query set, and appending rows with their fetchedAt timestamps so next month's run has something to diff against. Below: the minimal call, a script shaped for repeat runs, what's specific to the perplexity surface, and the failure modes that show up in practice.
The shortest honest answer: requests.post to https://api.agentgeo.org/v1/fetches with your key as a bearer token and {"query": ..., "surfaces": ["perplexity"]} as the body, raise_for_status(), then read answers[0]["sources"]. There is no SDK to install and none is needed — the client is four lines of requests. Everything after those four lines is bookkeeping, and the bookkeeping is what makes the data worth having twice.
The minimal call
One query, one surface. query and surfaces are the only two fields a perplexity request needs — the other request parameters (web_search, country, language) are honoured by different surfaces and have no effect here.
import requests
resp = requests.post(
"https://api.agentgeo.org/v1/fetches",
json={"query": "best uptime monitoring tools", "surfaces": ["perplexity"]},
headers={"Authorization": "Bearer ag_live_your_key_here"},
timeout=200, # the API holds slow scrapes up to 180s — stay above that
)
resp.raise_for_status()
answer = resp.json()["answers"][0]
for src in answer["sources"]:
print(f"{src['position']:>2}. {src['url']}")The response is one JSON object per run:
{
"id": "run_7d3a91c4e2f8",
"query": "best uptime monitoring tools",
"surfaces": ["perplexity"],
"status": "completed",
"recordsDelivered": 1,
"creditsCharged": 1,
"answers": [
{
"surfaceKey": "perplexity",
"answerText": "The tools that come up most often are ...",
"sources": [
{ "title": "Best Uptime Monitoring Tools Compared", "url": "https://example.com/uptime-tools", "position": 1 },
{ "title": "How We Monitor 200 Endpoints", "url": "https://example.org/blog/monitoring-setup", "position": 2 }
],
"fetchedAt": "2026-07-24T08:41:07Z"
}
]
}creditsCharged counts delivered records — one here. sources[] arrives ordered and already parsed; position starts at 1 and is the closest thing an answer engine has to a rank. The full field reference lives in the docs.
From one call to a dataset
A single pull answers today's question. The version worth keeping fetches a query set, normalizes every URL before counting — lowercase the host, drop www., strip the query string — and appends rows to a CSV with their fetchedAt timestamps. Appending is the point: the file accumulates into a run history, and next month's diff is a filter on the timestamp column.
#!/usr/bin/env python3
"""Fetch Perplexity's citations for a query set; append rows for future diffs."""
import csv
from collections import Counter
from pathlib import Path
from urllib.parse import urlparse, urlunparse
import requests
API = "https://api.agentgeo.org/v1/fetches"
KEY = "ag_live_your_key_here"
OUT = Path("perplexity_citations.csv")
FIELDS = ["query", "position", "host", "url", "title", "fetchedAt"]
QUERIES = [
"best uptime monitoring tools",
"uptime monitoring for small teams",
"status page software comparison",
"pingdom alternatives",
]
def normalize(url):
"""Lowercase host, drop www., strip the query string and trailing slash."""
parts = urlparse(url)
host = parts.netloc.lower().removeprefix("www.") # Python 3.9+
clean = urlunparse((parts.scheme, host, parts.path.rstrip("/"), "", "", ""))
return host, clean
def fetch(query):
resp = requests.post(
API,
json={"query": query, "surfaces": ["perplexity"]},
headers={"Authorization": f"Bearer {KEY}"},
timeout=200, # the API holds the request up to 180s — sit above it
)
resp.raise_for_status()
return resp.json()
hosts = Counter()
top_slot = Counter()
rows = []
for query in QUERIES:
run = fetch(query)
for answer in run["answers"]:
if answer.get("status") == "failed":
print(f"failed record, skipping: {query}")
continue
for src in sorted(answer.get("sources") or [], key=lambda s: s["position"]):
host, clean = normalize(src["url"])
hosts[host] += 1
if src["position"] == 1:
top_slot[host] += 1
rows.append({
"query": query,
"position": src["position"],
"host": host,
"url": clean,
"title": src["title"],
"fetchedAt": answer.get("fetchedAt", ""),
})
new_file = not OUT.exists()
with OUT.open("a", newline="", encoding="utf-8") as f:
writer = csv.DictWriter(f, fieldnames=FIELDS)
if new_file:
writer.writeheader()
writer.writerows(rows)
print(f"appended {len(rows)} rows to {OUT}")
print("\nmost-cited hosts")
for host, n in hosts.most_common(10):
print(f" {n:>2}x {host} (position 1: {top_slot[host]})")Two deliberate choices in there. normalize() returns both the host and a cleaned URL, because you aggregate by host but diagnose by page — eight citations of one domain could be one guide cited eight times or eight scattered posts, and only the URL column can tell you which. And the writer opens in append mode: overwrite the file and you're back to owning a snapshot instead of a history.
The script runs as-is once KEY is real. Create a free API key → — free tier, no credit card — or try one fetch without signing up.
How the perplexity surface behaves
All six engines share one request and response contract; what differs is behaviour. On perplexity:
- Sources are effectively always populated. Perplexity searches before it writes, so an answer without citations is rare. If your mental model comes from ChatGPT — where an empty
sources[]legitimately means the model didn't browse — recalibrate: on this surface the signal is never presence, it's which hosts fill the list and how the set moves between runs. queryplussurfacesis the entire request.web_searchis honoured only bychatgpt— Perplexity browses by design, there is nothing to switch on.languageaffects onlygoogle_ai_overview, andcountryonlygoogle_ai_overviewandcopilot. Extra parameters on aperplexitycall don't error; they sit in the payload doing nothing, implying behaviour the code isn't getting.- The citation set drifts unprompted. Perplexity re-searches on every ask, so two runs an hour apart can cite different pages with nobody's content having changed. One run is a sample. That's why the script appends rather than overwrites, and why a host should enter or leave across several runs before you call it movement.
positionranks within one answer only. First of four sources is not the same outcome as first of twelve. Keep the query next to the position in every row, and if you intend to trend positions, record the per-answer source count too.
For the task-level story — what citation data is for, and how far the manual route gets you — see how to pull citations from Perplexity. This page's job is the Python.
What breaks, and how to handle it
data=is notjson=.requests.post(url, data={...})form-encodes the body asapplication/x-www-form-urlencodedand the API rejects it. Thejson=kwarg serializes the dict and setsContent-Type: application/jsonin one move — use it, and delete the manual header if you added one.- A client timeout below 180 seconds cancels healthy requests. The API holds the connection for up to 180 seconds while a slow scrape finishes, so
timeout=30gives up on answers that were seconds from arriving — andrequestswith notimeoutat all hangs forever on a dead socket.timeout=200sits above the server's budget and below infinity. - Wall-clock adds up before credits do. Each call can legitimately run to three minutes and the script is sequential, so a 20-query set is worst-case an hour. Run it from cron or a worker, not inside a request handler, and make sure nothing upstream imposes its own shorter timeout.
- A failed record is not "zero citations". A record can carry
status: "failed"while the HTTP call returns 200 —raise_for_status()won't catch it, which is why the script checks and skips. And when the failure was a scrape exceeding the 180-second budget, the record costs zero credits and carriesproviderFields.snapshot_id: a follow-up POST with top-level"snapshot_id"and the same single surface redeems the finished answer instead of paying for a re-scrape. - Skipping normalization corrupts every count downstream.
www.example.com/guide/,example.com/guideandexample.com/guide?ref=pplxare one page; unnormalized they count as three, and the host table quietly deflates whoever gets linked with tracking parameters most. Thenormalize()above — lowercase host, dropwww., strip query string and trailing slash — is the least you can get away with.
Summary
Four lines of requests gets you Perplexity's sources for one query; the script above turns that into a dataset — normalized hosts, positions intact, a timestamp on every row. Perplexity's citation lists are dense and they drift, which makes append-and-diff the whole game. The same call works from Node.js and cURL, and the Python API page covers the endpoint beyond this one task.
Get a free API key → · Run one fetch without signing up → — Free tier, no credit card required.
Start for freeFrequently asked questions
- How do I get Perplexity citations in Python?
- POST to
https://api.agentgeo.org/v1/fetcheswithrequests, aBearer ag_live_key and the body{"query": "...", "surfaces": ["perplexity"]}, then readanswers[0]["sources"]— each entry carries atitle, aurland aposition. No headless browser, no HTML parsing; the citation list arrives already structured. - Is there a pip package or Python SDK for AgentGEO?
- No — there is no official SDK in any language, and the endpoint doesn't need one: the whole client is a
requests.postcall. If you'd rather your coding agent make the call than a script, the MCP server exposes the same fetch as a tool:claude mcp add agentgeo -- npx -y agentgeo-mcp --key ag_live_.... - Why does my requests call time out before the answer arrives?
- Almost always because the client timeout is below the server's. The API deliberately holds the request for up to 180 seconds while a slow surface finishes, so
timeout=30— or a framework default — cancels work that was about to complete. Settimeout=200, above the API's budget, and check that nothing upstream (a gateway, a job runner) imposes a shorter one. - Do I need to pass web_search, country or language for Perplexity?
- No.
web_searchis honoured only by thechatgptsurface — Perplexity browses by default.languageis honoured only bygoogle_ai_overview, andcountrybygoogle_ai_overviewandcopilot. Forperplexity,queryandsurfacesis the complete request; anything else in the payload is ignored. - Can the same Python script fetch citations from other engines?
- Yes — change the entry in
surfaces. ChatGPT, Perplexity, Google AI Overviews, Google AI Mode, Copilot and Gemini share an identical request and response shape, so the parsing, normalization and CSV code carry over untouched. What changes is density: Perplexity cites nearly every answer, whilechatgptonly carries sources when it browsed.
Keep reading