Tutorial2026-07-246 min read

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.

Tutorial

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.txt

Three 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>&1

tr -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 free

What 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_search is chatgpt-only. It's the one request field that nudges a fetch toward browsing, and chatgpt is the only surface that honours it. It raises the odds of a cited answer; it doesn't guarantee one.
  • country and language do nothing here. language maps to Google's hl and is read only by google_ai_overview; country steers google_ai_overview and copilot. The chatgpt surface 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 answerText and an empty sources[]. 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 -d bodies meet real queries. The first "what's" in a query closes the shell string and the API receives broken JSON. Build every body with jq -n --arg — the script does, and one-off commands should too.
  • --max-time copied 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 failed run isn't a lost run. A scrape that exceeds the 180-second budget returns status failed at zero credits with providerFields.snapshot_id on the record. POST again with top-level snapshot_id and 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.com and Example.com are one site split three ways. The two sub() calls plus ascii_downcase in 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.

Frequently asked questions

Can I get ChatGPT citations with one curl command?
Yes. POST https://api.agentgeo.org/v1/fetches with {"query": "...", "surfaces": ["chatgpt"], "web_search": true} and pipe through jq -r '.answers[0].sources[] | [.position, .url] | @tsv'. Each source carries title, url and position; the filter prints them ranked. Nothing is required beyond curl, jq and an ag_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_search nudges the fetch toward browsing, and browsing is when ChatGPT cites. Without it you'll see more answers drawn from model knowledge with an empty sources[]. It's honoured by the chatgpt surface 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-time copied 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