Get ChatGPT citations in Python
Getting ChatGPT's citations in Python takes one requests.post call - no SDK exists and none is needed. POST the query to a fetch endpoint with the chatgpt surface and the response carries a sources[] array with each cited page's title, URL and position, alongside the answer text and a fetchedAt timestamp. This page goes from that first call to a script worth scheduling: several queries, URLs normalized before counting, hosts aggregated with a Counter, and every row written to CSV so next month's pull has something to diff against.
Read this page with an AI
The short version: requests.post to https://api.agentgeo.org/v1/fetches with {"query": "...", "surfaces": ["chatgpt"]} and a bearer key, then read answers[0]["sources"] off the JSON. Everything below is that call plus the three additions a scheduled job actually needs - web_search to nudge the fetch toward browsing, a client timeout above the API's 180-second hold, and persistence so the numbers survive until the next run. For the task-level view - manual collection versus API, what the rows are for - see how to pull citations from ChatGPT; this page stays in the code.
The minimal call
Fourteen lines, one dependency. raise_for_status() and the 200-second timeout are not decoration - both come up again in the failure-modes section.
import requests
resp = requests.post(
"https://api.agentgeo.org/v1/fetches",
json={
"query": "best crm for small agencies",
"surfaces": ["chatgpt"],
"web_search": True, # chatgpt is the only surface that reads this
},
headers={"Authorization": "Bearer ag_live_your_key_here"},
timeout=200, # the API holds slow fetches up to 180s - stay above that
)
resp.raise_for_status()
for src in resp.json()["answers"][0]["sources"]:
print(src["position"], src["title"], src["url"])The response is small enough to read whole. No HTML, nothing to parse out of prose:
{
"id": "run_7c2e91b4d0aa",
"query": "best crm for small agencies",
"surfaces": ["chatgpt"],
"status": "completed",
"recordsDelivered": 1,
"creditsCharged": 1,
"answers": [
{
"surfaceKey": "chatgpt",
"answerText": "For a small agency the names that come up most are ...",
"sources": [
{
"title": "The 9 Best CRMs for Agencies",
"url": "https://example.com/blog/best-crm-agencies?utm_source=chatgpt.com",
"position": 1
},
{
"title": "Agency CRM Pricing Compared",
"url": "https://www.another-example.com/crm-pricing/",
"position": 2
}
],
"fetchedAt": "2026-07-22T09:14:03Z"
}
]
}Each sources[] entry is one page the answer drew on, in the order the answer ranked it - that is all the theory this page needs. Look at the two URLs instead: one arrived with a tracking parameter appended, the other with a www. and a trailing slash. Count them as-is across twenty queries and one page quietly becomes three rows. That's the first thing the longer script fixes.
From one call to a monthly job
One call answers who ChatGPT cited this morning. The question worth automating - which hosts keep getting cited across your topic - needs a loop over related queries, normalization before counting, and rows on disk with their timestamps. This is the whole script, Python 3.9+, requests the only install:
"""Pull ChatGPT's citations for a set of queries; aggregate and persist them."""
import csv
from collections import Counter
from datetime import date
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"
QUERIES = [
"best crm for small agencies",
"crm with built-in invoicing",
"how to choose a crm for a services business",
"hubspot alternatives for agencies",
]
def normalize(url: str) -> tuple[str, str]:
"""(host, clean_url): lowercase host, drop www., strip the query string."""
p = urlparse(url)
host = p.netloc.lower().removeprefix("www.")
clean = urlunparse((p.scheme, host, p.path.rstrip("/"), "", "", ""))
return host, clean
def fetch(query: str) -> dict:
resp = requests.post(
API,
json={"query": query, "surfaces": ["chatgpt"], "web_search": True},
headers={"Authorization": f"Bearer {KEY}"},
timeout=200, # the API waits up to 180s on slow fetches
)
resp.raise_for_status()
return resp.json()
def main() -> None:
hosts: Counter[str] = Counter()
rows = []
for query in QUERIES:
run = fetch(query)
for answer in run["answers"]:
sources = answer.get("sources") or []
if not sources:
# Real result: ChatGPT only cites when it browses.
# Keep the zero - it is diffable data too.
rows.append([query, "", "", "", "", answer.get("fetchedAt")])
print(f" 0 citations {query}")
continue
for src in sources:
host, clean_url = normalize(src["url"])
hosts[host] += 1
rows.append([
query,
src["position"],
host,
src["title"],
clean_url,
answer.get("fetchedAt"),
])
print(f"{len(sources):>3} citations {query}")
out = Path(f"chatgpt-citations-{date.today():%Y-%m-%d}.csv")
with out.open("w", newline="") as f:
writer = csv.writer(f)
writer.writerow(["query", "position", "host", "title", "url", "fetchedAt"])
writer.writerows(rows)
print(f"\n{len(rows)} rows -> {out}")
print("most-cited hosts:")
for host, n in hosts.most_common(10):
print(f"{n:>3} {host}")
if __name__ == "__main__":
main()The CSV filename carries the run date and every row carries fetchedAt - the part that looks like bookkeeping and is actually the product. One file is a snapshot; two files a month apart are a report: hosts that entered, hosts that dropped, your page's position moving from 6 to 2. None of that exists unless the rows were written down the first time.
Citation sets drift between runs - the same query can browse different pages an hour later. One pull is a sample, not a baseline. Schedule the script and read trends across several runs before treating any single change as real.
What's specific to the chatgpt surface
web_search is the one lever this surface has that the other five ignore. It's an optional boolean on the request, honoured only by chatgpt, and it nudges the fetch toward browsing - the mode that produces citations at all. If citations are why you're calling, send it. The flip side of the same matrix: country and language are read by other surfaces, not this one, so a chatgpt request should carry neither.
| Request field | Send it on chatgpt? | Why |
|---|---|---|
query | Always | The question, phrased the way a user would type it. |
surfaces | Always | ["chatgpt"] here. The same request shape serves all six engines - swapping the key is the whole port. |
web_search | Yes | Honoured only by chatgpt. Nudges the fetch toward browsing, which is when sources[] gets populated. |
country | No | Read by google_ai_overview and copilot only. On chatgpt it's dead weight. |
language | No | Read by google_ai_overview only - it maps to Google's hl. |
The other behaviour to design for: an answer with no citations is a completed, correct, billed result. web_search nudges; it doesn't force. A query the model can answer from its own weights may come back with a full answerText and an empty sources[]. The script above records that as a zero-row instead of retrying, because the zero is the datum - a query that never triggers browsing can't be won with a better page, and knowing that changes what you write.
Failure modes worth coding for
- A 30-second timeout kills the fetch, not the wait. Most codebases wrap HTTP calls in a 30- or 60-second timeout out of habit. The API holds a slow scrape open for up to 180 seconds, so a client timeout below that aborts requests that were about to complete.
timeout=200is the number that works; anything under 180 is a bug. - A fetch past the 180-second budget leaves a claim ticket. It comes back
status: "failed"at zero credits withproviderFields.snapshot_idon the record. POST again with top-level"snapshot_id"and the same single surface -["chatgpt"]- and the finished answer is redeemed without re-running the scrape. - A 4xx body parses as JSON too. Drop
raise_for_status()and an auth error flows straight intoresp.json(); the traceback you eventually get is aKeyError: 'answers'three functions from the actual problem. Keep the call directly after the POST. - Counting before normalizing splits your winners.
example.com/guide,www.example.com/guide/and the same URL with a tracking parameter are one page and threeCounterkeys. Lowercase the host, dropwww., strip the query string - or your most-cited page ranks third behind two copies of itself. - Retrying an empty
sources[]buys nothing. The model answered without browsing, and asking again mostly reproduces that - while each completed fetch is a charged record. Write the zero to the CSV and move on; "this query doesn't browse" is a finding, not an error.
Summary
One POST, timeout=200, raise_for_status(), read answers[0]["sources"] - the minimal version fits in fourteen lines of requests. The version worth keeping loops a handful of queries, sends web_search because chatgpt is the surface that honours it, normalizes URLs before counting, and writes dated rows to disk. Citations only become useful on the second pull; everything in the script exists so that second pull has a first one to compare against. The same request shape covers the other five engines - the Python API page shows the multi-surface version.
Get a free AI-visibility audit → · Read the docs → - No card, no account.
Get my free audit