Tutorial2026-07-256 min read

Get Perplexity citations in cURL

Perplexity ships a numbered source list with virtually every answer, and one curl command pulls it as JSON: POST the query to https://api.agentgeo.org/v1/fetches with an ag_live_ bearer key and read answers[0].sources[] — every entry carries a title, a url and a position, and jq turns that into a ranked TSV in one pipe. This page starts with that one-liner, then grows it into something worth scheduling: a loop over a queries file, host normalization, dated rows appended to a CSV so future runs can be diffed, one cron line, and the Perplexity-specific behaviour and failure modes that surface once it runs unattended.

Tutorial

The whole task is one pipe: curl -s against https://api.agentgeo.org/v1/fetches, jq on answers[0].sources[]. Perplexity cites almost everything it writes, so the engineering isn't getting sources — it's keeping the position order intact, normalizing the URLs before anything is counted, and appending every run somewhere a future run can be diffed against. All of that fits in shell. If you want the task-level view of manual collection versus the API, that's covered separately; this page stays in the terminal.

One curl, one ranked source list

The minimal call. --max-time 200 is not decoration: the API holds the request open for up to 180 seconds while the live scrape runs, so the client timeout has to sit above that — not at whatever shorter limit your environment quietly imposes.

curl -s --max-time 200 https://api.agentgeo.org/v1/fetches \
  -H "Authorization: Bearer ag_live_your_key_here" \
  -H "Content-Type: application/json" \
  -d '{"query": "best invoicing software for freelancers", "surfaces": ["perplexity"]}' \
  | jq -r '.answers[0].sources | sort_by(.position)[] | [.position, .url] | @tsv'

Output is two columns — position, URL — in the order Perplexity cited them. The full response behind that filter:

{
  "id": "run_7d31c9be52a4",
  "query": "best invoicing software for freelancers",
  "surfaces": ["perplexity"],
  "status": "completed",
  "recordsDelivered": 1,
  "creditsCharged": 1,
  "answers": [
    {
      "surfaceKey": "perplexity",
      "answerText": "For most freelancers the strongest options are ...",
      "sources": [
        { "title": "The Best Invoicing Software for Freelancers", "url": "https://example.com/invoicing-tools", "position": 1 },
        { "title": "Freelance Invoicing Apps Compared", "url": "https://reviews.example.org/invoicing", "position": 2 }
      ],
      "fetchedAt": "2026-07-25T07:12:40Z"
    }
  ]
}

status, recordsDelivered and creditsCharged are worth reading in scripts — a delivered record costs one credit, a failed one costs zero. And because the shaping lives in jq, moving from a list to a host tally is the same pipe with a longer filter:

curl -s --max-time 200 https://api.agentgeo.org/v1/fetches \
  -H "Authorization: Bearer ag_live_your_key_here" \
  -H "Content-Type: application/json" \
  -d '{"query": "best invoicing software for freelancers", "surfaces": ["perplexity"]}' \
  | jq -r '[.answers[0].sources[].url
      | sub("^https?://(www\\.)?"; "") | split("/")[0] | ascii_downcase]
      | group_by(.) | map({host: .[0], n: length}) | sort_by(-.n)[]
      | "\\(.n)\\t\\(.host)"'

Two commands from here to your own ranked list: create a free key — no credit card — or watch a live fetch first in the raw-answer playground.

Start for free

The script you'd actually schedule

One query answers who Perplexity cited this morning. The question worth automating is which hosts keep earning citations across a topic — and whether that set moved since the last run. That takes several queries, one normalization convention, and rows on disk. Queries live in a plain file, one per line:

best invoicing software for freelancers
freelance invoicing app with recurring billing
how to invoice international clients
#!/usr/bin/env bash
# perplexity-citations.sh — fetch citations for every query in queries.txt,
# append dated rows to a CSV, print this run's host tally.
set -euo pipefail

KEY="ag_live_your_key_here"
OUT="perplexity-citations.csv"
TODAY=$(date -u +%F)
HOSTS=$(mktemp)

[ -f "$OUT" ] || echo 'date,query,position,host,url,title' > "$OUT"

while IFS= read -r query; do
  [ -z "$query" ] && continue

  # jq builds the body, so quotes and apostrophes in a query can't break it.
  body=$(jq -cn --arg q "$query" '{query: $q, surfaces: ["perplexity"]}')

  resp=$(curl -s --max-time 200 https://api.agentgeo.org/v1/fetches \
    -H "Authorization: Bearer $KEY" \
    -H "Content-Type: application/json" \
    -d "$body") || { echo "curl failed: $query" >&2; continue; }

  if [ "$(echo "$resp" | jq -r '.status')" != "completed" ]; then
    # Failed records cost zero credits and may carry a snapshot_id to redeem.
    echo "$resp" | jq -c --arg q "$query" \
      '{q: $q, status, snapshot: .answers[0].providerFields.snapshot_id?}' >&2
    continue
  fi

  # The archive: one dated row per source, order preserved.
  echo "$resp" | jq -r --arg d "$TODAY" --arg q "$query" '
    .answers[0].sources | sort_by(.position)[]
    | [$d, $q, .position,
       (.url | sub("^https?://(www\\.)?"; "") | split("/")[0] | ascii_downcase),
       (.url | split("?")[0]),
       .title]
    | @csv' >> "$OUT"

  # This run's tally, counted off the response rather than the CSV.
  echo "$resp" | jq -r '.answers[0].sources[].url
    | sub("^https?://(www\\.)?"; "") | split("/")[0] | ascii_downcase' >> "$HOSTS"

done < queries.txt

echo "most-cited hosts this run:"
sort "$HOSTS" | uniq -c | sort -rn | head -10
rm -f "$HOSTS"

Two normalization decisions are baked in and worth stating out loud: the host column lowercases and strips www., so www.Example.com and example.com count as one; the url column drops the query string, so tracking parameters don't split one page into five rows. The tally is computed off the raw responses rather than the CSV on purpose — cut -d, against a CSV with quoted titles is a bug you find three weeks in.

The CSV is the artifact. Once every row carries a date, next month's question — which hosts entered the source lists, which dropped, whether your domain slid from position 2 to 6 — is a filter over two dates instead of a memory exercise. One line in crontab keeps the rows coming:

# crontab -e — every Monday 07:10 UTC; keep a log so silent failures aren't silent
10 7 * * 1 cd /home/you/geo && ./perplexity-citations.sh >> citations.log 2>&1

A run over twenty queries delivers twenty records and charges twenty credits; failed records charge nothing. Because every response reports creditsCharged, the cron log doubles as a spend audit — no dashboard required.

How Perplexity behaves on this endpoint

Perplexity is search-grounded: it retrieves first, then writes from what it retrieved, so sources are native to the product and virtually every answer carries them. That inverts the analysis compared with a chat engine — on chatgpt, whether an answer has citations at all is a finding; on perplexity, the questions are which hosts hold the top positions and how far the set drifts between runs. It also keeps the request body minimal, because the optional parameters mostly belong to other surfaces:

FieldHonoured on perplexity?Why
web_searchNo — chatgpt onlyBrowsing is inherent to Perplexity; there's no mode to toggle. Passing it changes nothing, so don't.
countryNo — google_ai_overview and copilot onlyLeave it out of the body rather than carrying a no-op parameter through your scripts.
languageNo — google_ai_overview onlyIt maps to Google's hl and nothing else.
snapshot_idYesRedeems a finished answer after a run that exceeded the 180-second budget; requires exactly one surface in the request.

The drift is real, not noise in the plumbing. Perplexity re-searches on every ask, so two runs an hour apart can cite different pages and reorder the ones they share. That is exactly what the dated rows exist for: a single run is a sample, and only a series of them separates a domain genuinely losing ground from ordinary between-run variance.

Where it breaks

In rough order of how soon you'll meet them:

  • Hand-built JSON bodies break on real queries. A query containing an apostrophe or a double quote truncates a single-quoted -d string or produces invalid JSON. Build the body with jq -cn --arg q "$query" and escaping stops being your problem.
  • A client timeout under 180 seconds throws away work in flight. The API holds the request open for up to 180 seconds on a slow scrape. --max-time 200 is the convention; the sneaky version is a CI step or proxy with its own shorter read timeout that kills the connection while the scrape is still running.
  • A failed record isn't gone. A scrape that exceeds the 180-second budget comes back status: "failed" at zero credits with a providerFields.snapshot_id on the record. POST again with top-level snapshot_id and the same single surface, and the finished answer is redeemed without paying to re-scrape.
  • URL noise quietly splits your counts. utm_ parameters, trailing slashes and www. variants make one page look like several. The script normalizes in jq before anything is counted — and if you ever change the convention, restate the whole CSV, because rows normalized two different ways can't be diffed.
  • An empty sources[] on Perplexity deserves a second look. On chatgpt a source-free answer is routine; on this surface it's rare enough to treat as a re-run candidate rather than a data point. Log it if it repeats — just don't let one odd record walk into the trend unexamined.

Summary

One curl returns Perplexity's ranked sources as JSON, one jq filter shapes them, and a loop over a queries file appending dated CSV rows makes the runs comparable — which is the part that matters, because on this surface the sources always exist and the signal is how they move. Keep --max-time above the 180-second hold, build request bodies with jq -cn, pick one URL normalization and keep it, and let cron do the collecting. If the job later outgrows the shell, the Python and Node.js versions hit the identical endpoint and shape.

Frequently asked questions

How do I get Perplexity's citations with curl?
POST to https://api.agentgeo.org/v1/fetches with {"query": "...", "surfaces": ["perplexity"]} and an Authorization: Bearer header, then read answers[0].sources[] — each entry carries title, url and position. Piped through jq -r '.answers[0].sources | sort_by(.position)[] | [.position, .url] | @tsv', it's a ranked list in one command.
What jq filter extracts just the cited URLs?
jq -r '.answers[0].sources | sort_by(.position)[].url' prints them in citation order. For a host tally, strip the scheme and www. with sub("^https?://(www\\.)?"; ""), take split("/")[0], lowercase it, then either group_by in jq or pipe the hosts through sort | uniq -c | sort -rn and let the shell count.
Why does my curl call to the API time out?
The API keeps the request open for up to 180 seconds while the live scrape runs, so any client-side limit below that — a wrapper's default, a proxy's read timeout, a CI step limit — kills the call mid-flight. Set --max-time 200. If the scrape itself exceeds the 180-second budget you get status: "failed" at zero credits with a providerFields.snapshot_id you can redeem in a follow-up call with the same single surface.
Do I need to pass web_search for Perplexity?
No — leave it out. web_search is honoured only by the chatgpt surface; Perplexity searches on every ask, which is exactly why nearly all of its answers carry sources. country and language don't apply to this surface either, so the body stays query plus surfaces.
Can I do this from Claude Code instead of the shell?
Yes — the same data is available over MCP: claude mcp add agentgeo -- npx -y agentgeo-mcp --key ag_live_... and your agent fetches the citation list mid-conversation. For scheduled collection, the curl script on this page is still the better fit: cron and flat files don't need an agent in the loop.

Keep reading