Tutorial7 min read2026-09-28

The Perplexity citations API, without the scraper

There is no official Perplexity citations API — Perplexity ships an answers endpoint for developers building on its models, but nothing that hands you the sources[] a Perplexity search actually cited. So you have two routes: scrape the surface yourself and parse the citation list out of HTML that changes without notice, or call a managed answers API that runs the query and returns those citations already structured. AgentGEO is the second route: one POST to https://api.agentgeo.org/v1/fetches with "surfaces": ["perplexity"] returns answers[0].sources[], each entry a title, a url and a position. Perplexity cites nearly every answer and the set drifts between runs, so the real work is downstream — normalize URLs, aggregate hosts, and append rows you can diff next month. Below: the one call, the sources[] shape, DIY scraping versus managed, and the failure modes that show up in production.

Tutorial

Read this page with an AI

The honest framing first: what people type into a search box as "perplexity citations api" doesn't exist as a first-party product. Perplexity's own developer API returns model completions; the citation list you see on perplexity.ai — the numbered sources under an answer — is not exposed as a clean endpoint you can POST a query to and get sources[] back. So the choice is between building a scraper and maintaining it forever, or calling something that already does. This page shows the managed call, then draws the DIY-vs-managed line honestly so you can decide.

The one call

query and surfaces are the entire request for Perplexity. The other parameters — web_search, country, language — are honoured by different surfaces and do nothing here, so leave them off. raise_for_status() and the 200-second timeout aren't decoration; both return in the failure-modes section.

Minimal — Perplexity's sources for one query
import requests

resp = requests.post(
    "https://api.agentgeo.org/v1/fetches",
    json={"query": "best uptime monitoring tools", "surfaces": ["perplexity"]},
    headers={"Authorization": "Bearer ag_live_your_key_here"},
    timeout=200,  # the API holds slow scrapes up to 180s — stay above that
)
resp.raise_for_status()

answer = resp.json()["answers"][0]
for src in answer["sources"]:
    print(f"{src['position']:>2}. {src['url']}")

The same request from the command line, for a quick sanity check before you wire it into anything:

curl — the same call
curl -sS https://api.agentgeo.org/v1/fetches \
  -H "Authorization: Bearer ag_live_your_key_here" \
  -H "Content-Type: application/json" \
  -d '{"query": "best uptime monitoring tools", "surfaces": ["perplexity"]}'

The response is one JSON object per run. No HTML, nothing to parse out of prose — the citation list arrives ordered and already structured:

Response, trimmed
{
  "id": "run_9b41c7d2e0af",
  "query": "best uptime monitoring tools",
  "surfaces": ["perplexity"],
  "status": "completed",
  "recordsDelivered": 1,
  "creditsCharged": 1,
  "answers": [
    {
      "surfaceKey": "perplexity",
      "answerText": "The tools that come up most often are ...",
      "sources": [
        { "title": "Best Uptime Monitoring Tools Compared", "url": "https://example.com/uptime-tools", "position": 1 },
        { "title": "How We Monitor 200 Endpoints", "url": "https://example.org/blog/monitoring-setup", "position": 2 },
        { "title": "Status Page Software Roundup", "url": "https://another-example.com/status-page-tools/", "position": 3 }
      ],
      "fetchedAt": "2026-09-28T08:41:07Z"
    }
  ]
}

creditsCharged counts delivered records — one here. position starts at 1 and ranks within this one answer only: first of three sources is not the same outcome as first of twelve, so keep the query and the per-answer source count next to the position whenever you plan to trend it. The full field reference lives in the docs.

From one call to a dataset

A single pull answers who Perplexity cited this morning. The version worth scheduling loops a query set, normalizes every URL before counting — lowercase the host, drop www., strip the query string — and appends rows with their fetchedAt timestamps. Appending is the whole point: the file accumulates into a run history, and next month's diff is a filter on the timestamp column.

perplexity_citations.py
#!/usr/bin/env python3
"""Pull Perplexity's citations for a query set; append rows for future diffs."""
import csv
from collections import Counter
from pathlib import Path
from urllib.parse import urlparse, urlunparse

import requests

API = "https://api.agentgeo.org/v1/fetches"
KEY = "ag_live_your_key_here"
OUT = Path("perplexity_citations.csv")
FIELDS = ["query", "position", "host", "url", "title", "fetchedAt"]

QUERIES = [
    "best uptime monitoring tools",
    "uptime monitoring for small teams",
    "status page software comparison",
    "pingdom alternatives",
]


def normalize(url):
    """(host, clean_url): lowercase host, drop www., strip query string + slash."""
    p = urlparse(url)
    host = p.netloc.lower().removeprefix("www.")  # Python 3.9+
    clean = urlunparse((p.scheme, host, p.path.rstrip("/"), "", "", ""))
    return host, clean


def fetch(query):
    resp = requests.post(
        API,
        json={"query": query, "surfaces": ["perplexity"]},
        headers={"Authorization": f"Bearer {KEY}"},
        timeout=200,  # sit above the server's 180s budget
    )
    resp.raise_for_status()
    return resp.json()


hosts = Counter()
top_slot = Counter()
rows = []

for query in QUERIES:
    run = fetch(query)
    for answer in run["answers"]:
        if answer.get("status") == "failed":
            print(f"failed record, skipping: {query}")
            continue
        for src in sorted(answer.get("sources") or [], key=lambda s: s["position"]):
            host, clean = normalize(src["url"])
            hosts[host] += 1
            if src["position"] == 1:
                top_slot[host] += 1
            rows.append({
                "query": query,
                "position": src["position"],
                "host": host,
                "url": clean,
                "title": src["title"],
                "fetchedAt": answer.get("fetchedAt", ""),
            })

new_file = not OUT.exists()
with OUT.open("a", newline="", encoding="utf-8") as f:
    writer = csv.DictWriter(f, fieldnames=FIELDS)
    if new_file:
        writer.writeheader()
    writer.writerows(rows)

print(f"appended {len(rows)} rows to {OUT}")
print("\nmost-cited hosts")
for host, n in hosts.most_common(10):
    print(f"  {n:>2}x  {host}  (position 1: {top_slot[host]})")

normalize() returns both the host and a cleaned URL because you aggregate by host but diagnose by page — eight citations of one domain could be one guide cited eight times or eight scattered posts, and only the URL column tells you which. The writer opens in append mode: overwrite the file and you're back to owning a snapshot instead of a history.

The script runs as-is once KEY is real — dry-run it first with a zero-credit ag_test_ key. Not ready for code? Get a free AI-visibility audit → — no card, no account.

Get my free audit

DIY scraping vs a managed API

The trade is real, so here it is straight. Scraping Perplexity yourself is free in dollars and expensive in everything else; a managed call inverts that. Which side wins depends on how many queries you run and how much you want to own a headless-browser fleet.

ConcernDIY scraperManaged answers API
Getting sources[]Parse them out of rendered HTML that Perplexity changes without notice.Returned structured — title, url, position — no parsing.
InfrastructureHeadless browsers, proxies, CAPTCHA handling, retries — a service to run.One HTTP call. Nothing to host.
MaintenanceBreaks whenever the DOM shifts; you're on call for it.Absorbed upstream — the response contract stays stable.
Cost shapeNo per-query fee, but proxy + engineering time is the bill.Per delivered record; creditsCharged counts what arrived.
Drift handlingYours to build — timestamps, dedupe, diffing all from scratch.fetchedAt on every record; append-and-diff is a CSV column.

Neither route removes the fundamental problem, which is that Perplexity's citations move on their own — see the next section. A scraper and a managed call give you the same drifting data; the managed call just spares you the fleet.

How the perplexity surface behaves

All six engines share one request and response contract; what differs is behaviour. On perplexity:

  • Sources are effectively always populated. Perplexity searches before it writes, so an answer without citations is rare. If your mental model comes from ChatGPT — where an empty sources[] legitimately means the model didn't browse — recalibrate: on this surface the signal is never presence, it's which hosts fill the list and how the set moves between runs.
  • The citation set drifts unprompted. Perplexity re-searches on every ask, so two runs an hour apart can cite different pages with nobody's content having changed. One run is a sample. That's why the script appends rather than overwrites, and why a host should enter or leave across several runs before you call it movement.
  • query plus surfaces is the whole request. web_search is honoured only by chatgpt — Perplexity browses by design, there's nothing to switch on. language affects only google_ai_overview, country only google_ai_overview and copilot. Extra parameters on a perplexity call don't error; they sit in the payload doing nothing, implying behaviour the code isn't getting.
  • position ranks within one answer only. First of four sources is not first of twelve. Keep the query beside the position in every row, and if you intend to trend positions, record the per-answer source count too.

Because Perplexity re-searches every time, treat any single pull as a sample, not a baseline. Schedule the script and read trends across several runs before calling a host in or out.

What breaks, and how to handle it

  • data= is not json=. requests.post(url, data={...}) form-encodes the body and the API rejects it. The json= kwarg serializes the dict and sets Content-Type: application/json in one move — use it, and delete any manual header.
  • A client timeout below 180 seconds cancels healthy requests. The API holds the connection for up to 180 seconds while a slow scrape finishes, so timeout=30 gives up on answers seconds from arriving — and requests with no timeout hangs forever on a dead socket. timeout=200 sits above the server's budget and below infinity.
  • Wall-clock adds up before credits do. Each call can legitimately run to three minutes and the script is sequential, so a 20-query set is worst-case an hour. Run it from cron or a worker, not inside a request handler, and make sure nothing upstream imposes a shorter timeout.
  • A failed record is not "zero citations". A record can carry status: "failed" while the HTTP call returns 200 — raise_for_status() won't catch it, which is why the script checks and skips. When the failure was a scrape exceeding the 180-second budget, the record costs zero credits and carries providerFields.snapshot_id: a follow-up POST with top-level "snapshot_id" and the same single surface redeems the finished answer instead of paying for a re-scrape.
  • Skipping normalization corrupts every count downstream. www.example.com/guide/, example.com/guide and example.com/guide?ref=pplx are one page; unnormalized they count as three, and the host table quietly deflates whoever gets linked with tracking parameters most. The normalize() above is the least you can get away with.

Summary

There's no first-party Perplexity citations API, so it's scrape-and-maintain or call-and-move-on. One POST with "surfaces": ["perplexity"] returns the sources already structured; the script turns that into a dataset — normalized hosts, positions intact, a timestamp on every row. Perplexity's lists are dense and they drift, which makes append-and-diff the whole game. The manual walkthrough is in how to pull citations from Perplexity, and the Python API page covers the endpoint beyond this one task.

FAQ

Direct answers to the questions this page raises.

No. Perplexity offers a developer API that returns model completions, but nothing that hands you the numbered sources a Perplexity search cited as a clean, structured endpoint. To get those sources[] you either scrape the surface and parse them out of HTML, or use a managed answers API like AgentGEO that runs the query and returns each citation's title, url and position already parsed.

POST to https://api.agentgeo.org/v1/fetches with {"query": "...", "surfaces": ["perplexity"]} and an Authorization: Bearer ag_live_... header, then read answers[0]["sources"] — each entry carries title, url and position. No headless browser, no HTML parsing; the citation list arrives already structured and ordered.

Because Perplexity re-searches the web on every ask. Two identical queries an hour apart can cite different pages with nobody's content having changed, so a single pull is a sample rather than a baseline. Append every run to a CSV with its fetchedAt timestamp and read trends across several runs before treating any one change as real movement.

Scraping is free in dollars but costs a headless-browser fleet, proxy handling and a parser that breaks whenever the DOM shifts. A managed API returns the same drifting citation data as one HTTP call with a stable contract, at a per-delivered-record cost. For anything beyond a handful of ad-hoc queries, the managed route usually wins on total cost of ownership.

No — there is no official SDK in any language, and none is needed: the endpoint is a single POST, so plain requests is the whole client. If you'd rather an agent make the call than a script, the same fetch is exposed over MCP: claude mcp add agentgeo -- npx -y agentgeo-mcp --key ag_live_....

Keep reading

Where this page leads next.

Run these checks on your own brand

Two ways in. Send a URL and a person runs the fetches for you, free — or connect your agent over MCP, on a plan, and run them yourself. Either way you get the raw answers and their citations, never a score.