Tutorial2026-08-126 min read

How to pull citations from ChatGPT answers

When ChatGPT browses for an answer it shows its work: a list of sources, each with a title, a URL and a position in that list. Read them in the browser and they're footnotes. Fetch them as data and they're something more useful — a ranked, timestamped set you can aggregate, store and, crucially, diff against the same set a month later. That last property is the whole point. A single citation list tells you who ChatGPT trusted this morning. A series of them tells you which domains are gaining ground in your category and which pages of yours quietly stopped being cited. Here's how to pull them, and what trips people up.

Tutorial

The shortest honest answer: ask ChatGPT your question, and if it browsed, expand the citations and copy the links. To do it repeatedly, fetch the answer through an API instead — each record comes back with a sources[] array of { title, url, position }, which you can aggregate by domain, write to a file, and compare against last week's.

The manual way (and why it stops working)

Type the question into ChatGPT. If the answer triggered browsing, it will carry source links — expand them, and you can see exactly which pages fed the reply. Copy them into a doc. For understanding one answer on one topic, this is genuinely the best tool available, and it costs nothing.

The trouble starts when the question is no longer "who did it cite?" but "who does it keep citing?"

  • One query at a time. Understanding which domains own a topic takes ten or twenty related questions, not one. Copying citations out of twenty answers by hand is an afternoon you won't spend twice.
  • Position gets lost in the copy-paste. The order matters — the first source is not doing the same work as the ninth — and it's the first thing that disappears when links go into a doc as a bullet list.
  • No history to diff. The interesting signal is which domains entered or dropped out since last month. If last month wasn't recorded in a comparable form, that signal doesn't exist.
  • Answers vary between runs. The same question asked twice can browse different pages. One hand-collected list is a snapshot of a moving target, and you can't tell drift from change.
  • It can't feed anything. A doc full of links can't drive a report, an alert or a content brief without a human transcribing it into a spreadsheet first.

Do it with one API call

AgentGEO returns each answer's citations already structured — no parsing links out of prose, no guessing at order. A single call, piped through jq, gives you the ranked URL list:

curl -s https://api.agentgeo.org/v1/fetches \
  -H "Authorization: Bearer ag_live_your_key_here" \
  -H "Content-Type: application/json" \
  -d '{"query": "best project management software for agencies", "surfaces": ["chatgpt"]}' \
  | jq -r '.answers[0].sources[] | [.position, .url] | @tsv'

To answer the bigger question — which domains does ChatGPT lean on across a topic — run several related queries, normalize each URL to its host, and count. A Counter does the aggregation; writing the rows out is what makes next month's comparison possible:

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

QUERIES = [
    "best project management software for agencies",
    "how to price agency retainers",
    "agency resource planning software",
]

hosts = Counter()
rows = []

for query in QUERIES:
    resp = requests.post(
        "https://api.agentgeo.org/v1/fetches",
        json={"query": query, "surfaces": ["chatgpt"]},
        headers={"Authorization": "Bearer ag_live_your_key_here"},
        timeout=200,  # live surfaces are slow - requests wait up to 180s
    )
    resp.raise_for_status()

    for answer in resp.json()["answers"]:
        sources = answer.get("sources") or []

        if not sources:
            # Legitimate result: ChatGPT only cites when it browses.
            print(f"no citations in this answer: {query}")
            continue

        for src in sources:
            host = urlparse(src["url"]).netloc.removeprefix("www.")
            hosts[host] += 1
            rows.append([
                query,
                src["position"],
                host,
                src["title"],
                src["url"],
                answer.get("fetchedAt"),
            ])

print("Most-cited domains")
for host, n in hosts.most_common(15):
    print(f"  {n:>3}  {host}")

# Keep the rows. A citation set is only worth much once you can diff it.
with open("citations.csv", "w", newline="") as f:
    writer = csv.writer(f)
    writer.writerow(["query", "position", "host", "title", "url", "fetchedAt"])
    writer.writerows(rows)

Four fields do all the work, and each one earns its place:

FieldWhat it isWhy it matters
titleThe page title as the engine rendered it.Readable label for a report — but it's the engine's rendering, not necessarily your <title> tag, so don't key anything on it.
urlThe full link the answer pointed at.Parse the host to aggregate by domain; keep the path to know which page actually earned the citation.
positionWhere the source ranked in that answer, starting at 1.Position 1 and position 9 are not the same outcome. Flattening the list to a set of domains throws this away.
fetchedAtTimestamp on the record.The anchor for every comparison. A citation set with no date can't be diffed against anything.

The valuable artifact isn't the top-15 table — it's the diff. Store rows keyed on query plus host plus path with their fetchedAt, and next month's report writes itself as a set operation: domains that entered, domains that dropped out, and pages that moved up or down in position. That's the report nobody can produce from browser screenshots, and it's the reason to keep the raw rows even when you only look at the summary.

Let your agent do it

When you're already in the editor working on the page that ought to be cited, the round trip through a script is friction. Install the MCP server and ask directly:

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

Then describe what you want out of the citations:

Fetch chatgpt's answer for "best project management software for agencies" and list every cited source with its position, title and domain. Tell me which domains appear more than once, whether acme.com is among them, and which of our pages under content/ is the closest match to the topics those sources cover.

The last clause is where an agent beats a script: it can hold the citation list and your actual content in the same context and tell you which existing page is one rewrite away from being the cited one. Once the check becomes routine, move it into the Python above and schedule it. The same server works in Claude Code, Zed, Windsurf and any other MCP client.

Common problems

  • An empty `sources[]` is a real result, not a bug. ChatGPT only cites when it browses; an answer produced from the model's own knowledge legitimately has no sources. Record the zero rather than retrying until you get links — "this question doesn't trigger browsing" is itself worth knowing, because no amount of content will earn a citation on a query that never fetches one.
  • One page, many URLs. Tracking parameters, trailing slashes, m. subdomains and AMP variants split a single page into several rows and quietly deflate its count. Normalize before you aggregate: strip the query string, drop www., lowercase the host.
  • Aggregating by host hides which page won. example.com cited eight times might be eight different articles or the same guide eight times — completely different situations, and only the second one tells you what to write. Keep the path in your rows even if you report at domain level.
  • Position is relative to its own answer. Being first among three sources isn't the same as being first among twelve. Store the number of sources in the answer alongside the position, or your trend line will move for reasons that have nothing to do with you.
  • Citation sets drift between runs. The same query can browse different pages an hour later, so one pull is a sample. Compare across several runs before calling anything a change — and note that slow scrapes which exceed the 180-second budget come back failed with a providerFields.snapshot_id at zero credits; retry with that id and the same single surface to collect the finished answer.

Summary

To read one answer's sources, expand the citations in ChatGPT — it's free and it's immediate. To learn which domains own a topic, or to notice the month your page stopped being cited, pull the citations as structured records, normalize the URLs, and keep the rows with their timestamps. Positions and dates are what turn a list of links into something you can actually compare.

Frequently asked questions

Does ChatGPT cite its sources?
It does when it browses the web to answer. Those answers carry a list of sources, and through the API each one arrives as a sources[] entry with a title, a URL and a position. Answers generated without browsing have no citations at all, so an empty sources[] is a legitimate result rather than a failure.
How do I get a list of the URLs ChatGPT cited?
POST https://api.agentgeo.org/v1/fetches with {"query": "...", "surfaces": ["chatgpt"]} and read answers[0].sources[] off the response — each entry has title, url and position. One jq filter turns that into a ranked URL list, and a short Python loop turns several queries into a count of the domains cited most often.
Why does a ChatGPT answer have no sources?
Because it answered from the model's own knowledge instead of browsing. Nothing is broken — the record still contains the full answerText, just an empty sources[]. It's a useful finding in itself: a query that never triggers browsing can't be won with a new page, so it's a positioning and brand-mention problem rather than a content-citation one.
Can I track which domains ChatGPT cites over time?
Yes, and that's the main reason to pull citations programmatically. Each record carries a fetchedAt timestamp, so if you store the rows — query, host, path, position, timestamp — comparing two dates becomes a set operation: which domains entered, which dropped, which moved up. AgentGEO returns the raw records; the storage and the diff are code you own.
What's the difference between a citation and a mention in ChatGPT?
A mention is your brand name appearing in the answer text. A citation is your page appearing in sources[] as evidence the answer drew on. They're independent — you can be cited without being named, or named without being cited — and they call for different fixes, so it's worth testing them separately.

Keep reading