> ## Documentation Index
> Fetch the complete documentation index at: https://docs.citino.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# CSV output

> Add format=csv to a list or report route and the page comes back as a CSV file, with every figure's band and sample size in their own columns.

## Which routes offer it

| Route | Rows |
| - | - |
| `GET /v1/projects` | Your projects |
| `GET /v1/projects/{projectId}/prompts` | Prompts |
| `GET /v1/projects/{projectId}/runs` | Answers collected (without their text) |
| `GET /v1/projects/{projectId}/searches` | Engine searches, or their words |
| `GET /v1/projects/{projectId}/reports/brands` | The brands report |
| `GET /v1/projects/{projectId}/reports/domains` | The domains report |
| `GET /v1/projects/{projectId}/reports/urls` | The URLs report |

On these routes `format=json` is the default. Every other route answers JSON only: leave `format` out there, because any value, `json` included, is a `400` whose message lists the routes above.

## What you get

* `Content-Type: text/csv; charset=utf-8`, one header row, then one line per item on the page, in the standard CSV format (lines end in a carriage return and a line feed; a cell containing a comma, a quote or a line break is quoted).
* **A figure becomes four columns**: its value, then `_band`, `_n` and `_low_sample`. `visibility` becomes `visibility`, `visibility_band`, `visibility_n` and `visibility_low_sample`. The value never travels without its band and sample size, so a chart drawn from the file can still show them.
* **Other objects become prefixed columns**: a row's `brand` becomes `brand_id`, `brand_name` and `brand_own`.
* **A list becomes one cell**, its values joined with `|`.
* **An empty value is an empty cell.** A figure with no answers behind it has an empty value cell and `0` in its `_n` column.
* **Columns are the union of the page's rows**, in the order first seen, so a row with a field the first row lacks keeps its column.
* **An empty page is an empty file**, with no header row: there is no row to take the columns from.

The CSV carries less than the JSON: a figure's `delta`, `within_noise`, `unit` and `state` are not in it, and neither is the response's `scope`. Keep the query you sent beside the file so you know what it counted.

## Text that looks like a formula

Brand names, prompts, URLs and search queries are other people's text. A spreadsheet would run a cell that starts with `=`, `+`, `-` or `@` as a formula, so any **text** cell that starts with one of those (or with a tab or a carriage return) is written with a `'` in front. Numbers are written as numbers, so a negative number stays a number.

## Paging a CSV

The next page is not in the body; it is in two response headers:

* `Citino-Next-Cursor`: the cursor for the next page. Absent on the last page.
* `Link`: the next page as a relative reference with `rel="next"`, for clients that follow links.

Request the next page with the same parameters plus `cursor`, and append its rows without its header line.

```bash curl theme={null}
# -D saves the headers, where the next page's cursor is.
curl "https://api.citino.ai/v1/projects/$PROJECT_ID/reports/brands?from=$FROM&to=$TO&dimensions=date&format=csv" -H "Authorization: Bearer $CITINO_API_KEY" -D headers.txt -o page.csv
grep -i '^citino-next-cursor' headers.txt
```

```python Python theme={null}
import os
import requests

BASE = "https://api.citino.ai/v1"
key = os.environ["CITINO_API_KEY"]
project_id = os.environ["PROJECT_ID"]
session = requests.Session()
session.headers["Authorization"] = f"Bearer {key}"

params = {"from": os.environ["FROM"], "to": os.environ["TO"], "dimensions": "date", "format": "csv"}
with open("brands.csv", "w", encoding="utf-8", newline="") as out:
    wrote_header = False
    while True:
        response = session.get(f"{BASE}/projects/{project_id}/reports/brands", params=params)
        response.raise_for_status()
        lines = response.text.splitlines(keepends=True)
        if lines:
            out.writelines(lines if not wrote_header else lines[1:])
            wrote_header = True
        cursor = response.headers.get("Citino-Next-Cursor")
        if not cursor:
            break
        params["cursor"] = cursor
```

```javascript JavaScript theme={null}
import { writeFile } from "node:fs/promises";

const BASE = "https://api.citino.ai/v1";
const headers = { Authorization: `Bearer ${process.env.CITINO_API_KEY}` };
const params = { from: process.env.FROM, to: process.env.TO, dimensions: "date", format: "csv" };

let csv = "";
for (;;) {
  const query = new URLSearchParams(params);
  const response = await fetch(
    `${BASE}/projects/${process.env.PROJECT_ID}/reports/brands?${query}`,
    { headers },
  );
  if (!response.ok) throw new Error(`Citino answered ${response.status}`);
  const text = await response.text();
  if (text) csv += csv ? text.slice(text.indexOf("\r\n") + 2) : text;
  const cursor = response.headers.get("Citino-Next-Cursor");
  if (!cursor) break;
  params.cursor = cursor;
}
await writeFile("brands.csv", csv);
```

> [!NOTE]
>
> Each page's columns come from that page's own rows. The samples above drop every later page's header line; a careful job checks that it matches the first page's before appending, and stops if it does not.

## Related

> [card] [Reports](https://docs.citino.ai/api/reports.md)
>
> Dimensions, filters and budgets.

> [card] [Pagination](https://docs.citino.ai/api/pagination.md)
>
> Cursors and why they belong to their query.

> [card] [Metric objects](https://docs.citino.ai/api/metric-objects.md)
>
> What the four figure columns mean.

> [card] [Pulling data nightly](https://docs.citino.ai/api/nightly-pull.md)
>
> A daily job that writes rows to a warehouse.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.