Tutorial2026-07-256 min read

Get Perplexity citations in Node.js

Getting Perplexity's citations in Node.js takes one POST and zero dependencies: send {"query": "...", "surfaces": ["perplexity"]} to https://api.agentgeo.org/v1/fetches with an ag_live_ key, and the answer comes back as JSON with sources[] already structured — title, url and position on every entry. Node 18+ ships fetch natively, so there is nothing to install and no HTML to parse. This page works through the whole job in Node: the minimal call, a production-shaped script that tracks a query set with host counts and a JSONL log, the behaviour specific to the Perplexity surface, and the failures worth coding for.

Tutorial

The short version: POST {"query": "...", "surfaces": ["perplexity"]} to https://api.agentgeo.org/v1/fetches with a Bearer ag_live_ key and read answers[0].sources[] off the response. Everything below is that call, first at its smallest, then grown into the script you would actually put on a schedule.

One POST, one ranked source list

Save this as citations.mjs and run node citations.mjs. ESM with top-level await is the least ceremony Node allows. The one non-obvious line is the timeout: the API holds a request open for up to 180 seconds while a slow surface finishes, so the client has to be more patient than that — 200 seconds is the convention.

// Node 18+, zero dependencies. Run: 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: ["perplexity"],
  }),
  signal: AbortSignal.timeout(200_000), // API waits up to 180s on slow surfaces
});
if (!resp.ok) throw new Error(`AgentGEO ${resp.status}: ${await resp.text()}`);

const run = await resp.json();
for (const src of run.answers[0].sources ?? []) {
  console.log(`${String(src.position).padStart(2)}. ${src.url}`);
}

The response is one JSON object per run, and everything you will use lives on the first entry of answers[]:

{
  "id": "run_7f3d09b2c4e1",
  "query": "best crm for small business",
  "surfaces": ["perplexity"],
  "status": "completed",
  "recordsDelivered": 1,
  "creditsCharged": 1,
  "answers": [
    {
      "surfaceKey": "perplexity",
      "answerText": "For small teams the most frequently recommended CRMs are ...",
      "sources": [
        { "title": "Best CRM Software for Small Business", "url": "https://example.com/best-crm", "position": 1 },
        { "title": "CRM Pricing Compared", "url": "https://example.org/crm-pricing", "position": 2 }
      ],
      "fetchedAt": "2026-07-24T08:41:57Z"
    }
  ]
}

A citation here is a page Perplexity retrieved and drew on, in the order it used them — that one sentence is all the theory this page needs. The fields that matter are position, which survives intact instead of getting scrambled in a copy-paste, url, which is exact, and fetchedAt, which is what makes two runs comparable later. The task-level story — manual collection versus API, and what to do with the data once you have it — lives at How to pull citations from Perplexity; this page stays in Node.

The script you'd actually schedule

One call answers today's question. The script below is shaped for the recurring one — which hosts keep earning citations across a query set, and whether the set moved since last run. It fetches each query, normalizes every URL to its host with new URL(), counts hosts in a Map, and appends one JSON line per answer to a log file with node:fs/promises. The log is the point: without stored runs there is nothing to diff next week.

// Node 18+, ESM, zero dependencies. Run: AGENTGEO_KEY=ag_live_... node track-citations.mjs
import { appendFile } from "node:fs/promises";

const KEY = process.env.AGENTGEO_KEY ?? "ag_live_your_key_here";
const LOG = "perplexity-citations.jsonl";
const QUERIES = [
  "best crm for small business",
  "crm with built-in email sequences",
  "hubspot alternatives for a five-person team",
];

// One malformed URL should cost one row, not the whole run.
function host(raw) {
  try {
    const h = new URL(raw).hostname.toLowerCase();
    return h.startsWith("www.") ? h.slice(4) : h;
  } catch {
    return null;
  }
}

async function fetchCitations(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: ["perplexity"] }),
    signal: AbortSignal.timeout(200_000),
  });
  if (!resp.ok) throw new Error(`AgentGEO ${resp.status}: ${await resp.text()}`);
  return resp.json();
}

const cited = new Map();   // host -> citations across the whole set
const topSlot = new Map(); // host -> position-1 citations

for (const query of QUERIES) {
  const run = await fetchCitations(query);

  for (const answer of run.answers) {
    if (answer.status === "failed") continue; // zero credits, nothing to count

    const sources = [...(answer.sources ?? [])].sort(
      (a, b) => a.position - b.position,
    );

    for (const src of sources) {
      const h = host(src.url);
      if (!h) continue;
      cited.set(h, (cited.get(h) ?? 0) + 1);
      if (src.position === 1) topSlot.set(h, (topSlot.get(h) ?? 0) + 1);
    }

    // One line per answer. Future diffs read this file, not your memory.
    const row = {
      fetchedAt: answer.fetchedAt,
      query,
      surfaceKey: answer.surfaceKey,
      sources: sources.map(({ position, url, title }) => ({ position, url, title })),
    };
    await appendFile(LOG, JSON.stringify(row) + "\n");
  }
}

console.log("most-cited hosts across the query set");
const ranked = [...cited.entries()].sort((a, b) => b[1] - a[1]).slice(0, 10);
for (const [h, n] of ranked) {
  console.log(`  ${String(n).padStart(2)}x  ${h}  (position 1: ${topSlot.get(h) ?? 0})`);
}

Each delivered record costs one credit, and failed records cost nothing — which is why the script skips them instead of logging empty rows. Run it weekly and perplexity-citations.jsonl accumulates comparable snapshots: a diff between two dates is a set operation over the host values, and a domain sliding from position 2 to position 6 is visible in the sorted sources without any extra bookkeeping.

Keep the full url in the log even though the report aggregates by host. example.com cited five times could be one guide cited five times or five different pages cited once each — different situations, and only the path tells them apart. The aggregation unit is a reporting choice; the log should preserve enough to change your mind later.

The three-query script above costs three credits — one per delivered record — and the free tier covers it many times over. Get a key and run it against your own queries; no credit card required.

Start for free

What the Perplexity surface honours

Perplexity is the densest citation surface of the six engines: it searches first and writes from what it retrieved, so virtually every answer carries sources. You will almost never handle the empty-sources[] case that chat surfaces produce. The working questions on this surface are which hosts hold the early positions and how the set moves between runs — and three behaviours are worth knowing before you extend the script:

  • The request stays at two fields. web_search is honoured only by the chatgpt surface — Perplexity browses inherently, so the code above rightly omits it. language is honoured only by google_ai_overview, and country only by google_ai_overview and copilot. For perplexity, query plus surfaces is the entire body.
  • position behaves like a soft ranking. The earliest sources do most of the work in the prose that gets written. Log the number, not just presence — a host sliding from 1 to 6 is a real decline that a present/absent flag hides completely.
  • Same-host repeats are normal. Perplexity routinely cites two or three pages from one domain in a single answer. Decide up front whether your counting unit is the host or the URL, and apply it consistently — switching conventions mid-quarter silently changes every number you have already reported.

Failure modes worth coding for

  • A 30-second timeout kills requests that would have succeeded. Node's fetch has no default timeout, but most people wrap one in — and the usual 10–30 s reflex is wrong here. The API holds a request up to 180 seconds while a slow surface finishes; AbortSignal.timeout(200_000) stays above that with margin. A TimeoutError at exactly your configured value is this problem, not a flaky network.
  • Slow scrapes come back failed — with a claim ticket. A scrape that exceeds the 180-second budget returns status: "failed" at zero credits with providerFields.snapshot_id on the record. A follow-up POST to /v1/fetches with top-level "snapshot_id" and the same single surface redeems the finished answer without paying for a re-scrape.
  • new URL() throws on strings that merely look like URLs. One malformed source URL will take down an unguarded loop forty queries in. Wrap the parse in try/catch, return null, skip the row — the aggregate barely notices a missing row; it very much notices a crash.
  • Sequential loops are slow by design here. Each request can legitimately hold for three minutes, so ten queries in a for loop can be a half-hour run. Fanning out with Promise.all — one request per query, each with its own AbortSignal — is safe; cap the parallelism if the query set grows past a handful.
  • Two runs disagreeing is not a bug. Perplexity re-searches on every ask, so the source list moves on its own. Treat one run as a sample and call something a change only after it holds across stored runs — that is what the JSONL log is for.

Summary

The minimal version is a dozen lines: one POST, answers[0].sources[], done. The version worth scheduling adds four things — a query set, host normalization behind a try/catch, a Map for the counts and a JSONL line per answer — and none of them require a dependency. Perplexity will hand you sources on nearly every run; the value is in keeping them, because the question that matters is not who was cited today but who entered, who dropped and who moved since last run. The same endpoint from other stacks, and the multi-engine fan-out, are covered on the Node.js API page.

Frequently asked questions

How do I get Perplexity citations in Node.js without an SDK?
There is no SDK to install and none needed: Node 18+ ships fetch, and the whole integration is one POST to https://api.agentgeo.org/v1/fetches with {"query": "...", "surfaces": ["perplexity"]} and a Bearer ag_live_ key. Read answers[0].sources[] off the JSON — each entry has title, url and position. The only npm package in the picture is agentgeo-mcp, and that is the MCP server for AI agents, not an HTTP client.
Why does my fetch to the citations API time out in Node?
Almost always because a client-side timeout sits below the API's own budget. The API holds a request open for up to 180 seconds while a slow surface finishes, so a 30- or 60-second AbortSignal aborts work that was going to succeed. Use AbortSignal.timeout(200_000) — above 180 with margin — and reserve retries for network errors rather than for patience the first request never got.
How do I count which domains Perplexity cites most often?
Normalize each sources[].url to a host — new URL(url).hostname, lowercased, www. stripped — and count into a Map across your query set. Sorting the entries by count gives you the domains that own the topic; a second Map tracking position-1 citations shows who holds the slot that does the most work. Keep the full URL in your stored rows so you still know which page earned each citation.
Does Perplexity always return sources in the API response?
Very nearly. Perplexity is search-grounded, so almost every answer carries a populated sources[] — the empty-citation case you plan for on chat surfaces barely exists here. What you do need to handle is the occasional record with status: "failed", which costs zero credits and should be skipped in aggregation rather than logged as an answer with no citations.
Can the same Node script pull citations from ChatGPT, Gemini or Copilot?
Yes — the request and response shapes are identical across all six engines, so the script changes by one string: put "chatgpt", "gemini", "google_ai_overview", "google_ai_mode" or "copilot" in surfaces instead. Expect different citation density, though: Perplexity cites nearly every answer, while chat surfaces only cite when they browse.

Keep reading