Tutorial2026-08-146 min read

How to pull citations from Perplexity answers

Every Perplexity answer arrives with a numbered source list, and that list is the closest thing answer engines have to a ranking. You can copy it out of the browser for one query. To collect it across a query set — positions preserved, hosts aggregated, every run stored so next week's can be diffed against this week's — POST the query to https://api.agentgeo.org/v1/fetches and read answers[].sources[], which comes back as structured JSON with title, url and position on every entry. This guide covers the manual copy, the one-call fetch in curl and Python, a short logger that turns snapshots into a trend, the agent version over MCP, and the normalisation traps that quietly corrupt citation data.

Tutorial

To pull citations from a Perplexity answer, take the numbered source list underneath it — each entry is a page the engine actually used, in the order it used them. By hand that's copy-paste. Programmatically it's one POST, and the response hands you sources[] with title, url and position already separated, which is what makes aggregating across a query set and diffing across weeks possible at all.

The manual way (and why it stops working)

It really is simple for one query. Ask Perplexity your question, scroll to the sources, and copy each link in order. Keep the order — position 1 is not the same result as position 8. Paste the links into a sheet next to the date and the query and you have one row of a citation log, which is more than most teams have.

Row two is where it falls apart:

  • Copying destroys the order. Multi-select in a browser, paste into a sheet, and the positions arrive scrambled — or as bare titles with the URLs stripped out.
  • Hosts are the unit you'll actually want. Rolling blog.globex.com/crm-guide and globex.com/pricing up into "Globex" is a parsing job, and by hand it's a parsing job you redo on every row.
  • A query set multiplies everything. Twenty queries at ten sources each, weekly, is two hundred links a week transcribed without a single typo. Forever.
  • No run history, no trend. The whole value of citation data is watching a domain climb or slip. One sheet of today's links can't show movement, and Perplexity re-searches every time, so movement is guaranteed.
  • Nothing downstream can read it. A column of pasted links doesn't feed an alert, a client report, or a diff against last month.

Do it with one API call

The API returns the citation list already parsed. Because Perplexity is search-grounded — it searches, then writes from what it found — nearly every answer carries one, so this is the densest citation surface of the six engines.

curl -X POST https://api.agentgeo.org/v1/fetches \
  -H "Authorization: Bearer ag_live_..." \
  -H "Content-Type: application/json" \
  -d '{"query": "best CRM for small business", "surfaces": ["perplexity"]}'

Each entry in sources[] has a title, a url and a position. That's enough to print a per-query citation list and, at the same time, aggregate which hosts keep showing up across the whole set:

import requests
from collections import Counter
from urllib.parse import urlparse

KEY = "ag_live_your_key_here"
QUERIES = [
    "best CRM for small business",
    "CRM software for a 10-person sales team",
    "simple CRM with email integration",
]


def host(url):
    h = urlparse(url).netloc.lower()
    return h[4:] if h.startswith("www.") else h


cited = Counter()     # how often each host appears anywhere in a source list
top_slot = Counter()  # how often each host is cited at position 1

for query in QUERIES:
    resp = requests.post(
        "https://api.agentgeo.org/v1/fetches",
        json={"query": query, "surfaces": ["perplexity"]},
        headers={"Authorization": f"Bearer {KEY}"},
        timeout=200,  # the API waits up to 180s for live surfaces
    )
    resp.raise_for_status()

    for answer in resp.json()["answers"]:
        if answer.get("status") == "failed":
            continue

        print(f"\n{query}")
        for src in sorted(answer.get("sources", []), key=lambda s: s["position"]):
            h = host(src["url"])
            cited[h] += 1
            if src["position"] == 1:
                top_slot[h] += 1
            print(f"  {src['position']:>2}. {h} — {src['title']}")

print("\nmost-cited hosts across the query set")
for h, n in cited.most_common(10):
    print(f"  {n:>2}x  {h}  (position 1: {top_slot[h]})")

One run of that is a snapshot: today's sources, and the hosts that recur across your queries. The reason to fetch citations programmatically is that you can keep them — append every run to a JSONL file and the same data becomes something you can diff:

# citation_log.py — call log_run(query, answer) inside the fetch loop above
import json
import pathlib
from datetime import datetime, timezone
from urllib.parse import urlparse

LOG = pathlib.Path("perplexity-citations.jsonl")


def host(url):
    h = urlparse(url).netloc.lower()
    return h[4:] if h.startswith("www.") else h


def log_run(query, answer):
    # One line per run: the query, when it ran, and the ordered source list.
    row = {
        "fetchedAt": answer.get("fetchedAt")
        or datetime.now(timezone.utc).isoformat(),
        "query": query,
        "surfaceKey": answer["surfaceKey"],
        "sources": [
            {"position": s["position"], "url": s["url"], "title": s["title"]}
            for s in answer.get("sources", [])
        ],
    }
    with LOG.open("a", encoding="utf-8") as f:
        f.write(json.dumps(row, ensure_ascii=False) + "\n")


def diff_last_two(query):
    # What entered and what left the citation list since the previous run.
    runs = [
        json.loads(line)
        for line in LOG.read_text(encoding="utf-8").splitlines()
        if line and json.loads(line)["query"] == query
    ]
    if len(runs) < 2:
        return set(), set()

    before = {host(s["url"]) for s in runs[-2]["sources"]}
    after = {host(s["url"]) for s in runs[-1]["sources"]}
    return after - before, before - after  # entered, dropped

Call log_run(query, answer) inside the fetch loop above, and diff_last_two() hands you two sets: the hosts that entered the citation list since the previous run, and the ones that dropped out. That's the report nobody can build from a screenshot — and each delivered record costs one credit, with failed records costing nothing.

Treat position as the ranking it effectively is. Perplexity writes its answer out of what it retrieved, and the first few sources do most of the work in the resulting prose. A domain sitting at position 1 across your query set is the page you have to beat; your own domain drifting from 2 to 7 is a real decline even though you're technically still cited. Log the position, not just the presence — presence alone is the flattest possible version of this data, and it hides exactly the movement you're looking for.

FieldWhat it isWhat to do with it
sources[].positionThe order the engine cited it inLog it — a slide from 2 to 7 is a decline that presence-only tracking hides
sources[].urlThe exact page cited, not just the domainTells you which page earned it, so you know what to write more of
sources[].titleThe title as the engine saw itCompare it against your real <title> — a mismatch is a freshness or rendering clue
answerTextThe prose those sources producedCheck whether being cited also got your brand named
fetchedAt / latencyMsWhen the record was collected, and how long it tookThe timestamp is what makes two runs comparable at all

Let your agent do it

Citation work is where an agent earns its keep: it can fetch the sources, then go read the page that outranked you and draft the answer to it. Connect the MCP server once and the pull becomes an instruction:

claude mcp add agentgeo -- npx -y agentgeo-mcp --key ag_live_...

agentgeo-mcp runs over stdio, has zero npm dependencies and needs Node.js 18+. Its single tool, fetch_raw_answers, returns each record verbatim — sources[] included, positions intact. Useful asks:

  • "Pull Perplexity's citations for our five category queries and give me the ten most-cited domains."
  • "Which pages does Perplexity cite at position 1 for 'best CRM for small business'? Read them and tell me what ours is missing."
  • "Append today's citation lists to geo-log.jsonl and tell me which hosts entered or dropped since last week."

The records come back unchanged — no scoring, no ranking, no conclusions. Everything downstream, from the diff to the content brief, happens in your agent with your context, and can be committed alongside the site it's about. Prefer to script it? The Python and curl paths hit the identical endpoint.

Common problems

Citation data corrupts quietly. These five are where it happens:

  • Decide early whether two pages from one domain count once or twice. Perplexity often cites several pages from the same site. Both conventions are defensible; picking one halfway through a quarter is not, because every number you derived changes with it.
  • URLs carry noise. Tracking parameters, trailing slashes and redirect wrappers make one page look like two. Normalise before aggregating: lowercase the host, strip a leading www., drop the query string.
  • Positions are per-answer, not global. Position 1 on a narrow long-tail query is not the same prize as position 1 on your head term. Always store the query next to the position, or the aggregate means nothing.
  • Re-search means real movement. Perplexity searches fresh on every ask, so the source list changes between runs on its own. That's precisely why you log runs rather than trusting one — a single delta is not a trend.
  • Not every run delivers. A record can return status: "failed" at zero credits with a providerFields.snapshot_id; a follow-up call with that id and the same single surface redeems the finished answer without paying to re-scrape. Skip failed records in your aggregation instead of logging them as "zero citations".

Summary

For a single answer, copy the source list out of the browser — it's right there and it costs nothing. For a query set you'll revisit, fetch it: sources[] arrives with titles, URLs and positions already separated, aggregation is a Counter, and appending each run to a JSONL file is what turns a pile of snapshots into the thing you actually wanted — a trend, with the position movement still in it.

Pull your first structured citation list from Perplexity. [Start free — no credit card](/onboarding) and store run one today.

Start for free

Frequently asked questions

How do I get the sources Perplexity used for an answer?
They're the numbered citations under the answer, and you can copy them from the browser. To get them as data, POST your query to https://api.agentgeo.org/v1/fetches with "surfaces": ["perplexity"] and read answers[].sources[] — every entry carries a title, a url and a position, so nothing has to be parsed out of prose or HTML.
Can I pull Perplexity citations programmatically?
Yes. AgentGEO runs a managed-scraper engine and returns each answer's citation list as structured JSON, so a normal HTTP request gets you the sources with their order intact. There's no headless browser to run, no proxies to rotate, and no selectors that break when the frontend changes.
What does position mean in a Perplexity citation list?
It's the order the engine cited the source in, and it behaves like a soft ranking: the earliest sources tend to do the most work in the answer that gets written. Logging position rather than a simple present/absent flag is what lets you see a domain slipping from 2 to 7 while still technically being cited.
How do I track Perplexity citations over time?
Store every run. Append each fetch to a JSONL file or a table with the query, the timestamp and the full source list, then diff the host sets between consecutive runs to see what entered and what dropped. Because Perplexity re-searches on every ask, a single run is a sample — only stored history turns it into a trend.
Can I pull citations from ChatGPT and Google AI Overviews the same way?
Yes — the surfaces array takes one to six engine keys and the record shape is identical across all of them, so the same parser works everywhere. The density differs, though: Perplexity is search-grounded and cites almost every answer, while a chatbot only links pages when it happens to browse.

Keep reading