Get ChatGPT citations in cURL
One curl POST to api.agentgeo.org/v1/fetches returns ChatGPT's answer for any query with its citations already structured — a sources array of title, url and position, ready for jq. No headless browser, no session cookies, no HTML parsing: the request is a bearer header and a JSON body naming the query and the chatgpt surface. This page is the shell version end to end — the one-liner, the JSON it returns, and the script you'd actually schedule: several queries, hosts normalized, dated rows appended to a CSV so next month's run has something to diff against — plus the ChatGPT behaviours the script has to expect.
Strip the task to its pieces and it's one pipeline: curl -s POSTs the query, the response carries a sources[] array on the answer record, and jq shapes it into whatever the next command needs — TSV, CSV, a host count. There is no SDK and none is needed; curl and jq are the whole toolchain. The rest of this page takes that pipeline to production shape: a queries file, a dated ledger, a cron line, and the failure modes the shell version actually hits.
Query in, ranked URLs out
One command, assuming an ag_live_ key from onboarding — or run a fetch keyless first in the playground. web_search: true nudges ChatGPT toward browsing, which is when citations appear, and --max-time 200 sits above the API's own 180-second hold on slow scrapes:
curl -s https://api.agentgeo.org/v1/fetches \
-H "Authorization: Bearer ag_live_your_key_here" \
-H "Content-Type: application/json" \
--max-time 200 \
-d '{"query": "best accounting software for freelancers", "surfaces": ["chatgpt"], "web_search": true}' \
| jq -r '.answers[0].sources[] | [.position, .url] | @tsv'Behind the jq filter, the full response — one record for the one surface requested, one credit charged for the one record delivered:
{
"id": "run_9c31e5d2a7f4",
"query": "best accounting software for freelancers",
"surfaces": ["chatgpt"],
"status": "completed",
"recordsDelivered": 1,
"creditsCharged": 1,
"answers": [
{
"surfaceKey": "chatgpt",
"answerText": "For most freelancers the strongest options are ...",
"sources": [
{ "title": "Best Accounting Software for Freelancers in 2026", "url": "https://example.com/accounting-freelancers", "position": 1 },
{ "title": "Freelance Bookkeeping: A Practical Guide", "url": "https://example.org/bookkeeping-guide", "position": 2 }
],
"fetchedAt": "2026-07-24T07:12:09Z"
}
]
}Two fields carry the analysis. position is the one to preserve — first-cited and ninth-cited are different outcomes, and flattening sources into a set of links throws the ranking away. fetchedAt is what makes two runs comparable at all; a citation list without a date can't be diffed against anything.
The script worth scheduling
A single list of URLs answers today's question. The recurring question — which hosts does ChatGPT keep citing across your topic — needs a queries file, a loop, and rows that outlive the run:
best accounting software for freelancers
how do freelancers invoice international clients
freelance tax software comparison#!/usr/bin/env bash
# citations.sh — ChatGPT citations for every query in queries.txt,
# appended as dated rows to citations.csv. The ledger is the product.
set -euo pipefail
KEY="ag_live_your_key_here"
CSV="citations.csv"
[ -f "$CSV" ] || echo "date,host,position,url,query,title" > "$CSV"
while IFS= read -r query; do
[ -n "$query" ] || continue
# jq -n builds the body, so apostrophes in queries can't break the JSON
body=$(jq -n --arg q "$query" '{query: $q, surfaces: ["chatgpt"], web_search: true}')
resp=$(curl -s --fail --max-time 200 https://api.agentgeo.org/v1/fetches \
-H "Authorization: Bearer $KEY" \
-H "Content-Type: application/json" \
-d "$body") || { echo "fetch failed: $query" >&2; continue; }
if [ "$(jq -r '.status' <<<"$resp")" != "completed" ]; then
# Slow scrapes come back failed at zero credits with a snapshot_id — see below
echo "run not completed: $query" >&2
continue
fi
# length of null is 0, so this also covers records with no sources at all
if [ "$(jq '.answers[0].sources | length' <<<"$resp")" -eq 0 ]; then
# Real result: ChatGPT only cites when it browses. Record the zero.
jq -r --arg q "$query" \
'[.answers[0].fetchedAt[:10], "-", 0, "-", $q, "no sources"] | @csv' \
<<<"$resp" >> "$CSV"
continue
fi
jq -r --arg q "$query" '
.answers[0] as $a
| $a.sources | sort_by(.position)[]
| [ ($a.fetchedAt[:10]),
(.url | sub("^https?://"; "") | sub("^www\\."; "") | split("/")[0] | ascii_downcase),
.position, .url, $q, .title ]
| @csv' <<<"$resp" >> "$CSV"
done < queries.txtThree decisions in there earn a sentence. The body is built with jq -n --arg, because the first apostrophe in a query would end a single-quoted -d string and hand the API broken JSON. The zero-citation branch writes a row instead of retrying — ChatGPT only cites when it browses, and the zero is the finding. And the column order puts date, host and position first: none of those can contain a comma, so cut -d, stays honest no matter what punctuation a page title ships with.
# Most-cited hosts across the whole ledger
tail -n +2 citations.csv | cut -d, -f2 | tr -d '"' | grep -v '^-$' \
| sort | uniq -c | sort -rn | head -15
# crontab -e — every Monday 07:00; each run adds a dated layer
0 7 * * 1 cd /home/you/geo && ./citations.sh >> citations.log 2>&1tr -d '"' strips the quoting @csv adds and grep -v drops the zero-citation placeholder rows. After a few cron cycles, the same cut over two date ranges gives you hosts that entered and hosts that dropped — the diff no browser session can produce. More shell idioms, including multi-engine calls against the same endpoint, live on the cURL API page.
Create a free key → and the script above runs as-is — free tier, no credit card required.
Start for freeWhat the chatgpt surface does differently
All six engines answer through the same contract, but the request options are not interchangeable, and two ChatGPT behaviours shape the script above.
web_searchis chatgpt-only. It's the one request field that nudges a fetch toward browsing, andchatgptis the only surface that honours it. It raises the odds of a cited answer; it doesn't guarantee one.countryandlanguagedo nothing here.languagemaps to Google'shland is read only bygoogle_ai_overview;countrysteersgoogle_ai_overviewandcopilot. Thechatgptsurface reads neither, so the script sends neither — pass a param only to a surface that honours it.- No browse, no citations. An answer produced from model knowledge has a complete
answerTextand an emptysources[]. That's a result, not an error — and a query that never triggers browsing can't be won with a better page, which is worth knowing before you commission one. - Citation sets drift between runs. The same query can browse different pages an hour apart. One run is a sample; the ledger exists so you compare several dated runs before calling movement a trend.
Where it breaks
- Single-quoted
-dbodies meet real queries. The first "what's" in a query closes the shell string and the API receives broken JSON. Build every body withjq -n --arg— the script does, and one-off commands should too. --max-timecopied from another snippet. 30 or 60 seconds kills the request while the scrape is still legitimately in flight; the API holds slow surfaces up to 180 seconds, so the client timeout belongs above that. 200 is the working convention.- A
failedrun isn't a lost run. A scrape that exceeds the 180-second budget returns statusfailedat zero credits withproviderFields.snapshot_idon the record. POST again with top-levelsnapshot_idand the same single surface —["chatgpt"]— and the finished answer is redeemed. The script logs these to stderr instead of swallowing them. - Retrying an empty
sources[]until links appear. Every retry costs a credit and reports the same fact. Record the zero and move on — drift means a later scheduled run may browse anyway, and the ledger will show it when it does. - Un-normalized hosts deflate the count.
www.example.com,m.example.comandExample.comare one site split three ways. The twosub()calls plusascii_downcasein the script cover the common cases; strip tracking parameters too if you diff at full-URL level.
Summary
One POST and one jq filter turn ChatGPT's citations into shell data: .answers[0].sources[] with title, url and position. The one-liner answers today's question; the loop and the CSV answer the better one — what changed since last run. Set --max-time 200, build bodies with jq -n, treat an empty sources[] as a recorded zero, and let cron do the remembering. The docs cover the full request and response contract.
Get a free API key → · Run a fetch without signing up → — Free tier, no credit card required.
Start for freeFrequently asked questions
- Can I get ChatGPT citations with one curl command?
- Yes.
POST https://api.agentgeo.org/v1/fetcheswith{"query": "...", "surfaces": ["chatgpt"], "web_search": true}and pipe throughjq -r '.answers[0].sources[] | [.position, .url] | @tsv'. Each source carriestitle,urlandposition; the filter prints them ranked. Nothing is required beyond curl, jq and anag_live_key. - Do I need web_search: true to get citations from ChatGPT?
- It isn't required, but it's the one lever you have:
web_searchnudges the fetch toward browsing, and browsing is when ChatGPT cites. Without it you'll see more answers drawn from model knowledge with an emptysources[]. It's honoured by thechatgptsurface only — the other five engines ignore it, so don't send it to them. - Why is sources[] empty in my curl response?
- The answer was generated without browsing, so there was nothing to cite. The record is still complete — full
answerText,fetchedAt, one credit — and the zero deserves a row in your ledger: a query that never triggers browsing can't be won with a better page, and no amount of retrying changes that. - What --max-time should I set on curl for this API?
- 200 seconds. The API holds a request for up to 180 seconds while a slow surface finishes, so the client timeout has to sit above that. A shorter
--max-timecopied from another script is the most common self-inflicted failure — curl kills the connection while the scrape is still legitimately in flight. - Can I get the same citation data from Claude Code instead of a script?
- Yes — the same data is available over MCP:
claude mcp add agentgeo -- npx -y agentgeo-mcp --key ag_live_..., then ask for the citation list in plain language. The shell version is still what you schedule; the MCP route is for when you're mid-edit and want the list in context.
Keep reading