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.
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 API key → · Try a fetch without signing up → — Free tier, no credit card required.
Start for freeFrequently asked questions
- How do I get ChatGPT citations with Python requests?
- POST to
https://api.agentgeo.org/v1/fetcheswith{"query": "...", "surfaces": ["chatgpt"], "web_search": true}and anAuthorization: Bearer ag_live_...header, then readanswers[0]["sources"]off the response — each entry carriestitle,urlandposition. Settimeout=200, callraise_for_status(), and that is the entire client; no SDK is involved. - Is there a pip package or Python SDK for AgentGEO?
- No — there is no official SDK in any language, and none is needed: the endpoint is a single POST, so plain
requestsis the whole integration. If you would rather not write the loop at all, the same data is exposed over MCP (claude mcp add agentgeo -- npx -y agentgeo-mcp --key ag_live_...), which lets an agent in your editor pull citations directly. - Why is sources[] empty when I fetch a ChatGPT answer?
- Because ChatGPT only cites when it browses, and not every query triggers browsing. The record still carries the full
answerText; it was produced from the model's own knowledge. Sendweb_search: trueto nudge the fetch toward browsing, and when it still comes back empty, record the zero — a query that never browses can't be won with a better page. - What timeout should I set on requests.post for this API?
- 200 seconds. The API holds the request open for up to 180 seconds on slow surfaces, so any client timeout below that aborts fetches that were about to complete.
timeout=200keeps the client above the server's budget with margin. Leavingrequestsat its default of no timeout also technically works, but then a genuine hang blocks your job forever. - Does web_search work on Perplexity or Gemini too?
- No.
web_searchis honoured only by thechatgptsurface; the other five ignore it. Likewiselanguageis read only bygoogle_ai_overview, andcountryonly bygoogle_ai_overviewandcopilot. The request and response shape is identical across all six engines, but a parameter the surface ignores should not be in the request.
Keep reading