Get the raw ChatGPT answer text via API
To get the raw ChatGPT answer text via API, POST your query to https://api.agentgeo.org/v1/fetches with "surfaces": ["chatgpt"] and read answers[0].answerText — the complete answer string, verbatim, no browser and no HTML to strip. Citations get most of the attention, but the answerText field is the one you want when the job is storing the full response, diffing it run over run, or running NLP on it: extracting brand mentions, measuring sentiment, tracking how the model describes you. This page focuses on that string — how to fetch it, why it's present even when sources[] is empty, and how to store it so next month's answer has something to compare against.
Read this page with an AI
The short version: answerText is a top-level string on each entry in the answers[] array, and it's the full answer the model produced — not a summary, not a citation list, the prose itself. requests.post to the fetch endpoint, raise_for_status(), then read answers[0]["answerText"]. Everything after that is what you do with a paragraph of text you now own: persist it, diff it, or feed it to an NLP pass. Crucially, answerText is present even when sources[] is empty — the model can answer without browsing, and the text is still the whole point.
Fetching the raw answer text
query and surfaces are the only fields required. web_search is optional and honoured only by chatgpt — send it if you want to nudge the fetch toward browsing, but the answer text arrives either way. Note the timeout: the API holds slow scrapes up to 180 seconds, so stay above it.
import requests
resp = requests.post(
"https://api.agentgeo.org/v1/fetches",
json={
"query": "is acme analytics good for enterprise",
"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 scrapes up to 180s - stay above that
)
resp.raise_for_status()
answer = resp.json()["answers"][0]
if answer.get("status") == "failed":
raise SystemExit("record failed - skip or redeem via snapshot_id")
text = answer["answerText"]
print(f"{len(text)} chars, {len(answer.get('sources') or [])} sources\n")
print(text)The response is one JSON object. answerText is the string you came for; sources[] sits beside it, and here it happens to be populated because the model browsed:
{
"id": "run_9f2b41c7e0da",
"query": "is acme analytics good for enterprise",
"surfaces": ["chatgpt"],
"status": "completed",
"recordsDelivered": 1,
"creditsCharged": 1,
"answers": [
{
"surfaceKey": "chatgpt",
"answerText": "Acme Analytics is generally well regarded for mid-market use, with strong dashboards and a fast setup. For enterprise, reviewers note gaps in role-based access and single sign-on compared with larger platforms ...",
"sources": [
{ "title": "Acme Analytics Review 2026", "url": "https://example.com/reviews/acme-analytics", "position": 1 },
{ "title": "Enterprise Analytics Compared", "url": "https://example.org/enterprise-analytics", "position": 2 }
],
"fetchedAt": "2026-09-30T07:22:14Z"
}
]
}That prose is the raw material for everything downstream. It names your product, describes it in a tone, and places it against competitors — none of which shows up in the citation list. If your question is 'how does ChatGPT talk about us,' the answer lives in this string, not in sources[].
answerText survives an empty sources[]
The single most useful property of answerText for this job: it's present whether or not the model browsed. ChatGPT only carries citations when it actually searched the web, so sources[] is often empty — but the answer text is always there, because the model always produces an answer. A citation-only scraper sees 'nothing.' The answer-text approach sees the whole response.
{
"id": "run_3c8a12f6b4de",
"query": "what is a good crm for a two-person agency",
"surfaces": ["chatgpt"],
"status": "completed",
"recordsDelivered": 1,
"creditsCharged": 1,
"answers": [
{
"surfaceKey": "chatgpt",
"answerText": "For a two-person agency, lightweight CRMs tend to fit best. Common picks include a simple pipeline tool you can set up in an afternoon rather than an enterprise suite ...",
"sources": [],
"fetchedAt": "2026-09-30T07:24:51Z"
}
]
}An empty sources[] here is not a failure and not a retry — it's a completed, billed, correct answer produced from the model's own weights. If you're mining the text for brand mentions or sentiment, this record is just as valuable as the browsed one; arguably more so, because it tells you how the model describes you unprompted, without a fresh page steering it.
Want to know how ChatGPT describes your brand right now — without writing any of this? Get a free AI-visibility audit → — no card, no account.
Get my free auditStore it, diff it, mine it
The answer text is only worth fetching if you keep it. One pull tells you today's phrasing; two pulls a month apart tell you whether the model's story about you changed. This script fetches a query set, stores each full answerText with its fetchedAt timestamp, and runs a first-pass NLP scan for brand mentions and a crude sentiment cue — enough to show the shape without pretending it's a model.
"""Fetch raw ChatGPT answer text for a query set; store it and scan for mentions."""
import json
import re
from datetime import date
from pathlib import Path
import requests
API = "https://api.agentgeo.org/v1/fetches"
KEY = "ag_live_your_key_here"
BRANDS = ["Acme Analytics", "Acme"]
POSITIVE = {"best", "strong", "great", "recommended", "reliable", "leading"}
NEGATIVE = {"lacks", "gaps", "weak", "limited", "missing", "expensive"}
QUERIES = [
"is acme analytics good for enterprise",
"acme analytics vs competitors",
"best analytics platform for mid-market",
"analytics tools with single sign-on",
]
def fetch(query):
resp = requests.post(
API,
json={"query": query, "surfaces": ["chatgpt"], "web_search": True},
headers={"Authorization": f"Bearer {KEY}"},
timeout=200, # the API holds slow scrapes up to 180s
)
resp.raise_for_status()
return resp.json()
def scan(text):
lower = text.lower()
mentions = sum(len(re.findall(re.escape(b.lower()), lower)) for b in BRANDS)
pos = sum(w in lower for w in POSITIVE)
neg = sum(w in lower for w in NEGATIVE)
cue = "positive" if pos > neg else "negative" if neg > pos else "neutral"
return mentions, cue
out = Path(f"chatgpt-answers-{date.today():%Y-%m-%d}.jsonl")
with out.open("w", encoding="utf-8") as f:
for query in QUERIES:
run = fetch(query)
for answer in run["answers"]:
if answer.get("status") == "failed":
print(f"failed, skipping: {query}")
continue
text = answer["answerText"] # present even when sources[] is empty
mentions, cue = scan(text)
record = {
"query": query,
"answerText": text,
"browsed": bool(answer.get("sources")),
"brand_mentions": mentions,
"sentiment_cue": cue,
"fetchedAt": answer.get("fetchedAt"),
}
f.write(json.dumps(record, ensure_ascii=False) + "\n")
print(f"{mentions} mentions {cue:>8} browsed={record['browsed']} {query}")
print(f"\nwrote {out} - full text stored, ready to diff next month")Two deliberate choices. The store is JSONL with the full answerText intact, because you diff prose against prose — truncate it and next month's comparison is meaningless. And browsed records whether sources[] was populated, so you can separate 'how the model talks about us when a page steered it' from 'how it talks about us from memory' — two different signals that a citation-only pull collapses into one.
The POSITIVE/NEGATIVE word sets are a placeholder, not a sentiment model — they show where a real NLP pass slots in. Swap in whatever you already run: a transformer classifier, an entity extractor, an LLM scoring the passage. The point of this page is getting the clean answerText string in the first place; what you run on it is your call.
Surface behaviour and failure modes
answerTextis always present;sources[]is conditional. The model always answers, so the text is always there. Citations appear only when it browsed. Build on the text as the primary field and treatsources[]as an optional enrichment, not a required one.- A failed record is a 200, not an exception. A record can carry
status: "failed"while HTTP returns 200 —raise_for_status()won't catch it, so checkanswer["status"]before readinganswerText. If the failure was a scrape past the 180-second budget, the record costs zero credits and carriesproviderFields.snapshot_id; POST again with a top-level"snapshot_id"and the same single surface to redeem the finished answer. - A client timeout under 180 seconds cancels healthy work. The API holds the connection up to 180 seconds while a slow scrape finishes;
timeout=30aborts answers that were about to arrive, and no timeout hangs forever.timeout=200is the number. - The answer drifts between runs. ChatGPT re-derives its answer each time, so the same query can return different phrasing an hour later even when nothing you control changed. One pull is a sample. Store with
fetchedAtand read trends across several runs before calling a change in tone real. web_searchchanges citations, not the text. Sendingweb_search: trueraises the odds the fetch browses and thus populatessources[]— it doesn't change whetheranswerTextexists. If all you want is the text, you can omit it; if you also want citations, send it.
Summary
The raw ChatGPT answer text is a top-level answerText string on each answer, fetched with one POST and read straight off the JSON — no browser, no HTML stripping. It's present whether or not the model browsed, which makes it the right field to store, diff and run NLP on when the question is how ChatGPT describes you rather than who it links to. Keep the full string with its fetchedAt, and the second pull becomes a report on how your story moved. The same request shape covers the other five engines and the Python API page shows the multi-surface version; if all you need is the citation list, get ChatGPT citations in Python is the narrower path.
Get a free AI-visibility audit → · Read the docs → — No card, no account.
Get my free audit