Tutorial2026-07-236 min read

Get ChatGPT citations in Node.js

POST one request from Node's built-in fetch and ChatGPT's citations come back as data: a sources[] array of { title, url, position } on the answer record, with a fetchedAt timestamp. No Puppeteer, no npm install — Node 18+ ships everything this integration needs. Below is the whole path in one language: a minimal script that prints a ranked citation list, then a production-shaped one that runs a query set, normalizes URLs with new URL(), counts hosts in a Map, and appends timestamped JSON Lines so next month's run has something to diff against — plus the two behaviours specific to ChatGPT and the failure modes worth knowing before you schedule it.

Tutorial

The shortest honest answer fits in a sentence: POST https://api.agentgeo.org/v1/fetches with {"query": "...", "surfaces": ["chatgpt"]} and a Bearer ag_live_ key, then read answers[0].sources[] — each citation is { title, url, position }, already ordered. Everything after that sentence is the difference between a one-off and a dataset: a client timeout that outlasts slow scrapes, URL normalization so one page counts once, and rows on disk with their fetchedAt so future runs can be compared.

One call, one ranked citation list

Node 18 made fetch global, which makes the integration exactly one file and zero npm install. Save this as citations.mjs — ESM, so top-level await just works:

// Node 18+ — built-in fetch, zero dependencies. Run with: node citations.mjs
const resp = await fetch("https://api.agentgeo.org/v1/fetches", {
  method: "POST",
  headers: {
    "Authorization": "Bearer ag_live_your_key_here",
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    query: "best crm for small business",
    surfaces: ["chatgpt"],
    web_search: true, // chatgpt-only: nudges the fetch toward browsing, which is when citations appear
  }),
  signal: AbortSignal.timeout(200_000), // the API holds slow scrapes up to 180s — stay above that
});
if (!resp.ok) throw new Error(`AgentGEO ${resp.status}: ${await resp.text()}`);

const { answers } = await resp.json();
for (const src of answers[0].sources) {
  console.log(src.position, src.url);
}

What comes back is one JSON document per run. Everything the rest of this page builds is made from three fields on each source — title, url, position — plus the record's fetchedAt:

{
  "id": "run_9f2e41c07ab8",
  "query": "best crm for small business",
  "surfaces": ["chatgpt"],
  "status": "completed",
  "recordsDelivered": 1,
  "creditsCharged": 1,
  "answers": [
    {
      "surfaceKey": "chatgpt",
      "answerText": "For most small teams the shortlist comes down to ...",
      "sources": [
        { "title": "Best CRM Software for Small Business", "url": "https://example.com/best-crm", "position": 1 },
        { "title": "CRM Pricing Compared", "url": "https://example.com/crm-pricing", "position": 2 }
      ],
      "fetchedAt": "2026-07-22T16:40:11Z"
    }
  ]
}

One record delivered, one credit charged, and the citations arrive already ranked — position starts at 1 and preserves the order ChatGPT presented its sources. If sources is empty, that's a real result rather than a transport error; the section on ChatGPT's behaviour below covers why.

The version you can schedule

A single list answers today's question. The version worth scheduling runs a related set of queries, normalizes every URL to a stable host and path, counts hosts in a Map, and appends each citation as one JSON line with its timestamp. JSON Lines beats rewriting a JSON array here: appends survive a crashed run, and a month of output stays greppable.

// pull-chatgpt-citations.mjs — Node 18+, ESM, no npm install.
// Runs a query set against chatgpt, counts normalized hosts, and appends
// one JSON line per citation so future runs can be diffed.
import { appendFile } from "node:fs/promises";

const KEY = process.env.AGENTGEO_KEY ?? "ag_live_your_key_here";
const OUT = "chatgpt-citations.jsonl";

const QUERIES = [
  "best crm for small business",
  "hubspot vs pipedrive for small teams",
  "cheapest crm with email sequences",
];

async function fetchChatgpt(query) {
  const resp = await fetch("https://api.agentgeo.org/v1/fetches", {
    method: "POST",
    headers: {
      "Authorization": `Bearer ${KEY}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({ query, surfaces: ["chatgpt"], web_search: true }),
    signal: AbortSignal.timeout(200_000),
  });
  if (!resp.ok) throw new Error(`AgentGEO ${resp.status}: ${await resp.text()}`);
  return resp.json();
}

// One page, one identity: lowercase host without www., path without the
// trailing slash, query string dropped (tracking params split counts).
function normalize(raw) {
  try {
    const u = new URL(raw);
    return {
      host: u.hostname.toLowerCase().replace(/^www\./, ""),
      path: u.pathname.replace(/\/+$/, "") || "/",
    };
  } catch {
    return null; // malformed URL in a source — rare, but don't let it kill the run
  }
}

const hostCounts = new Map();

for (const query of QUERIES) {
  let run;
  try {
    run = await fetchChatgpt(query);
  } catch (err) {
    console.error(`skipped "${query}": ${err.message}`);
    continue; // one bad query shouldn't cost you the other rows
  }

  for (const answer of run.answers) {
    const sources = answer.sources ?? [];

    if (sources.length === 0) {
      // Real result: ChatGPT only cites when it browses. Keep the zero —
      // "this query never triggers browsing" is a finding, not a failure.
      await appendFile(
        OUT,
        JSON.stringify({ query, sourceCount: 0, fetchedAt: answer.fetchedAt }) + "\n"
      );
      continue;
    }

    for (const src of sources) {
      const page = normalize(src.url);
      if (!page) continue;
      hostCounts.set(page.host, (hostCounts.get(page.host) ?? 0) + 1);
      await appendFile(
        OUT,
        JSON.stringify({
          query,
          host: page.host,
          path: page.path,
          position: src.position,
          sourceCount: sources.length,
          title: src.title,
          url: src.url,
          fetchedAt: answer.fetchedAt,
        }) + "\n"
      );
    }
  }
}

const ranked = [...hostCounts.entries()].sort((a, b) => b[1] - a[1]);
console.log("Most-cited hosts");
for (const [host, n] of ranked.slice(0, 15)) {
  console.log(String(n).padStart(4), "", host);
}

Two decisions in there are deliberate. The queries run sequentially — a live ChatGPT fetch can take a couple of minutes, and when one fails you want the failure attributable to its query rather than lost inside a Promise.all rejection. And every row carries sourceCount: position 2 of 3 and position 2 of 12 are different outcomes, and the denominator is unrecoverable later if you didn't store it.

Row fieldWhy it's in every line
queryThe grouping key. Three related queries say more about a topic than one, but only if each row remembers which question produced it.
host + pathHost feeds the aggregate count; path tells you which page actually earned the citation. Reporting at domain level is fine — storing at domain level throws the answer away.
position + sourceCountRank only means something against its denominator. Store both or your trend line moves for reasons that have nothing to do with you.
fetchedAtThe anchor for every comparison. Rows without timestamps are a list; rows with them are a series.

The JSONL file is the actual product of this script — the top-15 console table is a byproduct. Once two runs exist, the interesting report is a set operation per query on host + path: who entered, who dropped, whose position moved. That's ten lines of Node against two files, and it's the report you cannot reconstruct if the first run was never written down.

What only ChatGPT does

web_search: true appears in both scripts because ChatGPT is the one surface that honours it. The flag nudges the fetch toward browsing, and browsing is the only path to citations — an answer written from model weights alone cites nothing. It's a nudge, not a switch: some queries still come back without sources, which is the second behaviour worth planning for.

An empty sources[] is data. The record still carries the full answerText at the normal one credit; ChatGPT simply didn't browse for that query. The script above appends a sourceCount: 0 row instead of retrying, because a query that never triggers browsing can't be won with a better page — and you only learn which of your queries those are if the zeros are in the file. One more thing to leave out: country and language exist in the API but ChatGPT ignores both (country steers google_ai_overview and copilot, language only google_ai_overview), so on this surface they're dead weight in the request body.

The same query can browse different pages an hour later. One run is a sample, not the truth — schedule the script and read trends across several runs before concluding a page was dropped. When you're ready to ask the same questions of the other five engines, the request shape doesn't change; the six-engine fan-out lives on the Node.js API page.

Where it breaks first

  • An AbortError long before the API gave up. The endpoint holds a request for up to 180 seconds while a slow surface finishes, so a habitual AbortSignal.timeout(30_000) cancels scrapes that were about to succeed. The house convention is 200_000 — above the server's budget, below forever.
  • A scrape that outlives even the 180-second budget. It comes back 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 instead of re-scraped.
  • One page, four rows. ?utm_source=, trailing slashes and www. variants split a single page's count into fragments. The normalize() above handles it by construction — new URL(), lowercase host, path only — but if you roll your own, normalize before the Map, not in the report.
  • web_search copied into a multi-surface fan-out. Only chatgpt reads the flag. If you extend the script to other engines, build the request body per surface and drop the field everywhere else — the contract is to send a surface only the parameters it honours.
  • Hunting for npm install agentgeo. There is no SDK in any language; built-in fetch is the integration. The one real npm package is agentgeo-mcp, the MCP server (claude mcp add agentgeo -- npx -y agentgeo-mcp --key ag_live_...) — for when the consumer is an agent in your editor rather than a script.

Summary

One fetch gets you a ranked, structured citation list for any query ChatGPT browses; a script of about forty lines turns that into a dataset with hosts normalized, denominators stored and every row timestamped. Keep the zeros, keep the paths, keep the file — the JSONL is what makes the second month more informative than the first. The Python and cURL versions of this page hit the same endpoint with the same fields, so the language changes nothing about the data.

Frequently asked questions

How do I get ChatGPT's citations in Node.js without Puppeteer?
POST to https://api.agentgeo.org/v1/fetches with {"query": "...", "surfaces": ["chatgpt"]} and a Bearer ag_live_ key, using the fetch built into Node 18+. Each answer record carries sources[] with title, url and position — the citations, already structured and ordered. No browser automation, no proxy pool, no npm dependency at all.
Why is sources[] empty in my response?
Because ChatGPT only cites when it browses, and this answer came from the model's own knowledge. The record still contains the full answerText at the normal one credit. Setting "web_search": true nudges the fetch toward browsing but doesn't force it — record the zero instead of retrying, since a query that never browses can't be won with a better page.
What timeout should I set on fetch for this API?
200 seconds — AbortSignal.timeout(200_000). The API holds a request for up to 180 seconds while slow surfaces finish, so any client timeout below that aborts scrapes that were about to complete. Anything above the 180-second server budget works; 200 seconds is the convention used throughout the docs.
Do country and language work on a chatgpt request?
You can send them, but ChatGPT ignores both, so leave them off. In the current matrix, language is honoured only by google_ai_overview (Google's hl) and country by google_ai_overview (gl) and copilot (it steers the Bing results underneath). The only optional field chatgpt reads is the web_search boolean.
How do I store ChatGPT citations so I can diff them later?
Append one JSON line per citation — query, host, path, position, sourceCount, fetchedAt — with node:fs/promises. JSON Lines appends cleanly from a cron job and greps cleanly a month later, and the diff between two dates becomes a set operation on host plus path per query. The API returns the records; the file and the diff are code you own.

Keep reading