The Perplexity sources JSON, field by field
The Perplexity sources JSON is a flat array on each answer object: answers[0].sources, where every entry is exactly three fields — title, url and position. There's no nesting, no HTML, no prose to parse; it arrives ordered and ready to read. This page is a reference for that shape, field by field: what each key means, how the enclosing answer object frames it, how to pull it with jq at the shell and with Python in a script, and how to normalize the url so one page doesn't count as three. If you want the how-to for building a citations pipeline, that's elsewhere — this page is about the data shape itself.
Read this page with an AI
The short version: a Perplexity fetch returns one answers[] entry, and that entry carries a sources[] array of { title, url, position } objects. position starts at 1 and orders sources within that one answer. Perplexity cites nearly every answer, so the array is almost always populated — which makes this the surface where the parsing is trivial and the normalization is where the care goes. Below: the full object, each field defined, and jq and Python that read it the same way.
The full shape, once
Here's a complete response for a single-surface Perplexity fetch. Everything this page describes is visible in it — the run-level envelope, the answer object, and the sources[] array inside it.
{
"id": "run_5e1c83a90f2b",
"query": "best time tracking apps for freelancers",
"surfaces": ["perplexity"],
"status": "completed",
"recordsDelivered": 1,
"creditsCharged": 1,
"answers": [
{
"surfaceKey": "perplexity",
"answerText": "For freelancers, the apps that come up most are ...",
"sources": [
{
"title": "9 Best Time Tracking Apps for Freelancers",
"url": "https://www.example.com/blog/time-tracking?utm_source=perplexity",
"position": 1
},
{
"title": "Freelance Time Tracking Compared",
"url": "https://example.org/time-tracking-compared/",
"position": 2
},
{
"title": "How I Bill Hours as a Contractor",
"url": "https://example.net/billing-hours",
"position": 3
}
],
"fetchedAt": "2026-09-30T08:03:22Z"
}
]
}Two levels matter. The run-level envelope — id, query, status, recordsDelivered, creditsCharged — describes the fetch as a whole. The answer object inside answers[] describes what one surface returned, and sources[] lives there, not at the top. Get that nesting right and everything else is field lookups.
Field by field
The source object is three keys. The answer object and envelope around it add the context you need to store and diff them.
| Field | Where | What it means |
|---|---|---|
title | source object | The cited page's title as Perplexity presents it — display text, not a stable identifier. Don't key on it; two runs can phrase the same page's title differently. |
url | source object | The cited page's URL. This is the identity of the citation, but raw — it may carry a tracking parameter, a www., or a trailing slash. Normalize before you count. |
position | source object | 1-based rank within this one answer. First of three is not first of twelve — position is only comparable next to the answer's own source count. |
surfaceKey | answer object | "perplexity" here. Identifies which engine produced this answer when a fetch spans several surfaces. |
answerText | answer object | The full answer prose. Not part of the citation shape, but the string the sources back. |
fetchedAt | answer object | ISO timestamp for this answer. The column that makes a row diffable — store it with every source. |
creditsCharged | envelope | Delivered records billed — 1 for a single populated answer. A failed record costs 0. |
The one trap in that table is position. It's a rank inside a single answer, so it's meaningless without the source count beside it — record how many sources the answer had if you ever plan to trend positions, or 'moved from 3 to 1' will mislead you when the list also shrank from twelve to three.
Want your citation data pulled, normalized and tracked without writing the parser? Get a free AI-visibility audit → — no card, no account.
Get my free auditReading it with jq
For a quick look or a shell pipeline, jq reads the shape directly. The path is .answers[0].sources[] — the array lives on the answer, not the envelope. Dry-run the fetch first with an ag_test_ key to spend zero credits while you get the pipeline right.
curl -sS https://api.agentgeo.org/v1/fetches \
-H "Authorization: Bearer ag_live_your_key_here" \
-H "Content-Type: application/json" \
-d '{"query": "best time tracking apps for freelancers", "surfaces": ["perplexity"]}' \
--max-time 200 \
| jq -r '
.answers[0]
| select(.status != "failed")
| .sources[]
| [.position, .title, .url]
| @tsv
'The select(.status != "failed") guard matters: a record can carry status: "failed" while the HTTP response is still 200, so filter it before you index into .sources[] or jq errors on the missing array. To count hosts straight from the shell, extract the host and pipe through sort | uniq -c:
curl -sS https://api.agentgeo.org/v1/fetches \
-H "Authorization: Bearer ag_live_your_key_here" \
-H "Content-Type: application/json" \
-d '{"query": "best time tracking apps for freelancers", "surfaces": ["perplexity"]}' \
--max-time 200 \
| jq -r '
.answers[0].sources[]
| .url
| sub("^https?://(www\\.)?"; "") # strip scheme and www.
| sub("[/?].*$"; "") # keep host only
' \
| sort | uniq -c | sort -rnReading it in Python
In a script the same shape reads the same way, and Python's urllib.parse does the normalization more carefully than a regex can. This is the reference parse: guard the failed record, iterate sources[] in position order, and return a clean host plus a canonical URL for every entry.
"""Parse the Perplexity sources JSON: title, url, position -> normalized rows."""
import requests
from urllib.parse import urlparse, urlunparse
API = "https://api.agentgeo.org/v1/fetches"
KEY = "ag_live_your_key_here"
def normalize(url):
"""(host, clean_url): lowercase host, drop www., strip query and trailing slash."""
p = urlparse(url)
host = p.netloc.lower().removeprefix("www.") # Python 3.9+
clean = urlunparse((p.scheme, host, p.path.rstrip("/"), "", "", ""))
return host, clean
resp = requests.post(
API,
json={"query": "best time tracking apps for freelancers", "surfaces": ["perplexity"]},
headers={"Authorization": f"Bearer {KEY}"},
timeout=200, # the API holds slow scrapes up to 180s - stay above that
)
resp.raise_for_status()
answer = resp.json()["answers"][0]
if answer.get("status") == "failed":
raise SystemExit("record failed - nothing to parse")
source_count = len(answer.get("sources") or [])
for src in sorted(answer["sources"], key=lambda s: s["position"]):
host, clean = normalize(src["url"])
print(f"{src['position']:>2}/{source_count} {host:<28} {src['title']}")
# host, clean, src['title'], src['position'], answer['fetchedAt']
# -> the five columns worth persisting per sourceWhy urllib.parse over a regex: it handles the awkward URLs correctly — ports, uppercase hosts, empty paths, fragments — where a hand-rolled pattern quietly mangles one shape in fifty and skews your host counts. www.example.com/guide/, example.com/guide and example.com/guide?utm_source=perplexity are one page; the normalize() above collapses all three to example.com/guide, which is the difference between an honest host table and one that's inflated by whoever gets linked with tracking parameters most.
Keep the raw url alongside the normalized one when you persist. 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 original URL can tell you which after the fact.
Shape notes and failure modes
sources[]is on the answer, not the envelope. The most common parse bug is reaching forresponse["sources"]— it isn't there. The array lives atanswers[0]["sources"], because a fetch can span several surfaces and each carries its own list.- Perplexity almost always populates it. Unlike ChatGPT — where an empty
sources[]legitimately means 'didn't browse' — Perplexity searches before it writes, so an empty array here is rare. On this surface the signal is which hosts fill the list and how the set moves, not whether it's present. - A failed record is a 200 with no usable sources.
status: "failed"can arrive while HTTP is 200, soraise_for_status()won't catch it and indexing.sources[]will break. Guard onstatusfirst. If the failure was a scrape past the 180-second budget, the record costs 0 credits and carriesproviderFields.snapshot_id— redeem it with a follow-up POST carrying a top-level"snapshot_id"and the same single surface. positionis a within-answer rank. It orders sources inside one answer and nothing more. Store the answer's source count next to it or comparisons across runs mislead when the list length changes.- The set drifts between runs. Perplexity re-searches on every ask, so two fetches an hour apart can carry different URLs with no content changed. One response is a sample — persist with
fetchedAtand diff across runs before calling a host in or out. titleis display text, not an ID. It can vary run to run for the same page. Key your dedup and aggregation on the normalizedurl, never ontitle.
Summary
The Perplexity sources JSON is a flat { title, url, position } array at answers[0].sources — no nesting, no HTML, ordered by a 1-based position that ranks within one answer. Parse it identically with jq or Python; guard the failed record first, then normalize the url so one page counts once. title is display text, position needs its source count for context, and the array drifts between runs, so persist with fetchedAt and diff. For the citations pipeline built on this shape see get Perplexity citations in cURL and in Python; the cURL API page covers the endpoint beyond this one surface.
Get a free AI-visibility audit → · Read the docs → — No card, no account.
Get my free audit