Tutorial7 min read2026-09-30

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.

Tutorial

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.

A complete Perplexity fetch response
{
  "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.

FieldWhereWhat it means
titlesource objectThe 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.
urlsource objectThe 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.
positionsource object1-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.
surfaceKeyanswer object"perplexity" here. Identifies which engine produced this answer when a fetch spans several surfaces.
answerTextanswer objectThe full answer prose. Not part of the citation shape, but the string the sources back.
fetchedAtanswer objectISO timestamp for this answer. The column that makes a row diffable — store it with every source.
creditsChargedenvelopeDelivered 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 audit

Reading 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 + jq — flatten sources to a table
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 + jq — most-cited hosts, normalized
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 -rn

Reading 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_sources.py — the reference parse
"""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 source

Why 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 for response["sources"] — it isn't there. The array lives at answers[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, so raise_for_status() won't catch it and indexing .sources[] will break. Guard on status first. If the failure was a scrape past the 180-second budget, the record costs 0 credits and carries providerFields.snapshot_id — redeem it with a follow-up POST carrying a top-level "snapshot_id" and the same single surface.
  • position is 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 fetchedAt and diff across runs before calling a host in or out.
  • title is display text, not an ID. It can vary run to run for the same page. Key your dedup and aggregation on the normalized url, never on title.

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.

FAQ

Direct answers to the questions this page raises.

Each entry in the sources[] array is exactly three fields: title (the cited page's display title), url (the cited page's address, raw), and position (a 1-based rank within that one answer). The array sits on the answer object at answers[0].sources, alongside surfaceKey, answerText and fetchedAt. There's no nesting inside a source and no HTML — it arrives ordered and ready to read.

Nested. sources[] is a property of the answer object, at answers[0]["sources"], not on the run-level envelope. Reaching for response["sources"] is the most common parse bug. It's nested because one fetch can span several surfaces, and each surface's answer carries its own sources list, so the array has to hang off the per-answer object.

Use the path .answers[0].sources[], and guard the failed record first: .answers[0] | select(.status != "failed") | .sources[]. From there [.position, .title, .url] | @tsv flattens each source to a row. A record can be status: "failed" while HTTP is 200, so filtering before you index into .sources[] keeps jq from erroring on the missing array.

Because www.example.com/guide/, example.com/guide and example.com/guide?utm_source=perplexity are one page but three distinct strings — counted raw, one page becomes three rows and your host table inflates whoever gets linked with tracking parameters most. Lowercase the host, drop www., and strip the query string and trailing slash. Python's urllib.parse does this more reliably than a regex because it handles ports, uppercase hosts and empty paths correctly.

Rarely. Perplexity searches before it writes, so nearly every answer carries citations — unlike ChatGPT, where an empty sources[] legitimately means the model didn't browse. An empty or absent array on Perplexity usually means the record failed, not that there were no sources. Check status first: a "failed" record can arrive at HTTP 200 with no usable sources, and if it exceeded the 180-second budget it carries a snapshot_id you can redeem.

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.