Tutorial2026-07-246 min read

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.

Tutorial

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.

Get a free key

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.
  • query plus surfaces is the entire request. web_search is honoured only by chatgpt — Perplexity browses by design, there is nothing to switch on. language affects only google_ai_overview, and country only google_ai_overview and copilot. Extra parameters on a perplexity call 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.
  • position ranks 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 not json=. requests.post(url, data={...}) form-encodes the body as application/x-www-form-urlencoded and the API rejects it. The json= kwarg serializes the dict and sets Content-Type: application/json in 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=30 gives up on answers that were seconds from arriving — and requests with no timeout at all hangs forever on a dead socket. timeout=200 sits 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 carries providerFields.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/guide and example.com/guide?ref=pplx are one page; unnormalized they count as three, and the host table quietly deflates whoever gets linked with tracking parameters most. The normalize() above — lowercase host, drop www., 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.

Frequently asked questions

How do I get Perplexity citations in Python?
POST to https://api.agentgeo.org/v1/fetches with requests, a Bearer ag_live_ key and the body {"query": "...", "surfaces": ["perplexity"]}, then read answers[0]["sources"] — each entry carries a title, a url and a position. 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.post call. 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. Set timeout=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_search is honoured only by the chatgpt surface — Perplexity browses by default. language is honoured only by google_ai_overview, and country by google_ai_overview and copilot. For perplexity, query and surfaces is 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, while chatgpt only carries sources when it browsed.

Keep reading