The Perplexity citations API, without the scraper
There is no official Perplexity citations API — Perplexity ships an answers endpoint for developers building on its models, but nothing that hands you the sources[] a Perplexity search actually cited. So you have two routes: scrape the surface yourself and parse the citation list out of HTML that changes without notice, or call a managed answers API that runs the query and returns those citations already structured. AgentGEO is the second route: one POST to https://api.agentgeo.org/v1/fetches with "surfaces": ["perplexity"] returns answers[0].sources[], each entry a title, a url and a position. Perplexity cites nearly every answer and the set drifts between runs, so the real work is downstream — normalize URLs, aggregate hosts, and append rows you can diff next month. Below: the one call, the sources[] shape, DIY scraping versus managed, and the failure modes that show up in production.
Read this page with an AI
The honest framing first: what people type into a search box as "perplexity citations api" doesn't exist as a first-party product. Perplexity's own developer API returns model completions; the citation list you see on perplexity.ai — the numbered sources under an answer — is not exposed as a clean endpoint you can POST a query to and get sources[] back. So the choice is between building a scraper and maintaining it forever, or calling something that already does. This page shows the managed call, then draws the DIY-vs-managed line honestly so you can decide.
The one call
query and surfaces are the entire request for Perplexity. The other parameters — web_search, country, language — are honoured by different surfaces and do nothing here, so leave them off. raise_for_status() and the 200-second timeout aren't decoration; both return in the failure-modes section.
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 same request from the command line, for a quick sanity check before you wire it into anything:
curl -sS https://api.agentgeo.org/v1/fetches \
-H "Authorization: Bearer ag_live_your_key_here" \
-H "Content-Type: application/json" \
-d '{"query": "best uptime monitoring tools", "surfaces": ["perplexity"]}'The response is one JSON object per run. No HTML, nothing to parse out of prose — the citation list arrives ordered and already structured:
{
"id": "run_9b41c7d2e0af",
"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 },
{ "title": "Status Page Software Roundup", "url": "https://another-example.com/status-page-tools/", "position": 3 }
],
"fetchedAt": "2026-09-28T08:41:07Z"
}
]
}creditsCharged counts delivered records — one here. position starts at 1 and ranks within this one answer only: first of three sources is not the same outcome as first of twelve, so keep the query and the per-answer source count next to the position whenever you plan to trend it. The full field reference lives in the docs.
From one call to a dataset
A single pull answers who Perplexity cited this morning. The version worth scheduling loops a query set, normalizes every URL before counting — lowercase the host, drop www., strip the query string — and appends rows with their fetchedAt timestamps. Appending is the whole point: the file accumulates into a run history, and next month's diff is a filter on the timestamp column.
#!/usr/bin/env python3
"""Pull 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):
"""(host, clean_url): lowercase host, drop www., strip query string + slash."""
p = urlparse(url)
host = p.netloc.lower().removeprefix("www.") # Python 3.9+
clean = urlunparse((p.scheme, host, p.path.rstrip("/"), "", "", ""))
return host, clean
def fetch(query):
resp = requests.post(
API,
json={"query": query, "surfaces": ["perplexity"]},
headers={"Authorization": f"Bearer {KEY}"},
timeout=200, # sit above the server's 180s budget
)
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]})")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 tells you which. 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 — dry-run it first with a zero-credit ag_test_ key. Not ready for code? Get a free AI-visibility audit → — no card, no account.
DIY scraping vs a managed API
The trade is real, so here it is straight. Scraping Perplexity yourself is free in dollars and expensive in everything else; a managed call inverts that. Which side wins depends on how many queries you run and how much you want to own a headless-browser fleet.
| Concern | DIY scraper | Managed answers API |
|---|---|---|
Getting sources[] | Parse them out of rendered HTML that Perplexity changes without notice. | Returned structured — title, url, position — no parsing. |
| Infrastructure | Headless browsers, proxies, CAPTCHA handling, retries — a service to run. | One HTTP call. Nothing to host. |
| Maintenance | Breaks whenever the DOM shifts; you're on call for it. | Absorbed upstream — the response contract stays stable. |
| Cost shape | No per-query fee, but proxy + engineering time is the bill. | Per delivered record; creditsCharged counts what arrived. |
| Drift handling | Yours to build — timestamps, dedupe, diffing all from scratch. | fetchedAt on every record; append-and-diff is a CSV column. |
Neither route removes the fundamental problem, which is that Perplexity's citations move on their own — see the next section. A scraper and a managed call give you the same drifting data; the managed call just spares you the fleet.
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. - 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.
queryplussurfacesis the whole request.web_searchis honoured only bychatgpt— Perplexity browses by design, there's nothing to switch on.languageaffects onlygoogle_ai_overview,countryonlygoogle_ai_overviewandcopilot. Extra parameters on aperplexitycall don't error; they sit in the payload doing nothing, implying behaviour the code isn't getting.positionranks within one answer only. First of four sources is not first of twelve. Keep the query beside the position in every row, and if you intend to trend positions, record the per-answer source count too.
Because Perplexity re-searches every time, treat any single pull as a sample, not a baseline. Schedule the script and read trends across several runs before calling a host in or out.
What breaks, and how to handle it
data=is notjson=.requests.post(url, data={...})form-encodes the body and the API rejects it. Thejson=kwarg serializes the dict and setsContent-Type: application/jsonin one move — use it, and delete any manual header.- 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 seconds from arriving — andrequestswith no timeout 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 a 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. 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 is the least you can get away with.
Summary
There's no first-party Perplexity citations API, so it's scrape-and-maintain or call-and-move-on. One POST with "surfaces": ["perplexity"] returns the sources already structured; the script turns that into a dataset — normalized hosts, positions intact, a timestamp on every row. Perplexity's lists are dense and they drift, which makes append-and-diff the whole game. The manual walkthrough is in how to pull citations from Perplexity, and the Python API page covers the endpoint beyond this one task.
Get a free AI-visibility audit → · Read the docs → — No card, no account.
Get my free audit