# Limits and conventions

The rules every endpoint follows: dates, paging, rate limits, retries, sizes, freshness and privacy.

## Requests and responses

- **Base URL:** `https://app.punchlineanalytics.com`. All endpoints are under `/v1`.
- **HTTPS only**, JSON in and out (`Content-Type: application/json` on bodies). Field names are `snake_case`.
- **Single results** come back as `{"data": {…}}`, **lists** as `{"data": […]}`, plus `next_cursor` where a list pages. `POST /v1/query` is the exception: `{"query", "results", "meta"}`.
- **Report endpoints** add `dashboard_url`: the same view in the dashboard, for linking people to it.
- **Errors** always use [one shape](https://punchlineanalytics.com/docs/api-reference#errors), with a `code` you can branch on.
- **Server-side only.** The API sends no CORS headers, so browsers block calls from web pages. That's deliberate: a key in a web page is readable by anyone. Call the API from your servers.
- Responses aren't cached (`Cache-Control: no-store`). Cache on your side if you poll.

## Dates and times

- Days are **UTC** and written `YYYY-MM-DD`. A range's `to` day is included: `{"from": "2026-09-01", "to": "2026-09-30"}` is all of September.
- Report endpoints take `from` and `to` as query parameters. Leave out `to` for today, and `from` for 6 days before `to` (a week).
- Timestamps are ISO 8601 in UTC, like `2026-10-06T14:50:00.000Z`.
- Today is partial: it includes traffic up to the last rollup refresh (below).

## Data freshness and history

- Totals come from rollups refreshed every **10 minutes**. `meta.rollups_refreshed_at` says when.
- Individual page-view records, and so agent sessions and their evidence, are kept for **90 days**. Daily totals are kept, so queries over older ranges still work.
- Labels are fixed when a request arrives. When Punchline learns about a new agent, it applies from then on; older traffic keeps the label it had.

## Pagination

`GET /v1/sessions` returns the newest sessions first, `limit` at a time (1–100, default 25), with a `next_cursor`. Pass it back as `cursor` for the next page; it's `null` on the last page.

```bash
curl "https://app.punchlineanalytics.com/v1/sessions?limit=100" -H "Authorization: Bearer $PUNCHLINE_API_KEY"
# → {"data": [...], "next_cursor": "eyJl..."}
curl "https://app.punchlineanalytics.com/v1/sessions?limit=100&cursor=eyJl..." -H "Authorization: Bearer $PUNCHLINE_API_KEY"
```

Cursors are opaque: don't build or edit them. Keep the same filters while paging. The [SDK](https://punchlineanalytics.com/docs/sdk) does this for you with `allSessions()`.

Other lists are bounded by `limit` instead. `POST /v1/query` returns up to 1,000 rows and says `"truncated": true` when there were more. For bigger pulls, use an [export](https://punchlineanalytics.com/docs/api#exports) (up to 100,000 rows).

## Rate limits

120 requests a minute for each key. Every response carries:

| Header | Meaning |
|---|---|
| `RateLimit-Limit` | Requests allowed in the window (120) |
| `RateLimit-Remaining` | Requests left in the current window |
| `RateLimit-Reset` | Seconds until the window resets |

Over the limit you get `429` [`rate_limited`](https://punchlineanalytics.com/docs/api-reference#rate-limited) with `Retry-After` in seconds. Wait that long, then continue. The SDK does this automatically, twice by default.

To stay well under the limit, ask for more per request: one query with a `day` dimension instead of one query per day, and 100 sessions per page instead of 25.

## Retries and idempotency

Reads are always safe to retry. For writes, send an `Idempotency-Key` header, any 1–128 visible ASCII characters you choose, such as a UUID or your own record's ID:

```bash
curl https://app.punchlineanalytics.com/v1/events \
  -H "Authorization: Bearer $PUNCHLINE_API_KEY" \
  -H "Idempotency-Key: order-10482" \
  -H "Content-Type: application/json" \
  -d '{"site_id": "…", "events": [{"name": "purchase", "value": 249}]}'
```

- A retry with the same key and the same body gets the first response back, and nothing is written twice.
- The same key with a different body is refused with `422` [`idempotency_key_reused`](https://punchlineanalytics.com/docs/api-reference#idempotency-key-reused).
- Keys are remembered for at least 24 hours, separately for each API key.
- Supported on `POST /v1/events` and on every create, change and delete under alerts, goals, triggers and URL parameters.

Retry `429` after `Retry-After`, and `500` with exponential backoff (1 s, 2 s, 4 s…). Don't retry other `4xx` errors: the request needs fixing first.

## Sizes

| Limit | Value |
|---|---|
| Request body | 64 KB |
| `POST /v1/events` body | 1 MB, up to 1,000 events |
| Event age | Within the last 36 hours (and not more than 5 minutes ahead) |
| `POST /v1/query` rows | 1,000 per request |
| Export rows | 100,000; files up to 4 MB compressed, kept 24 hours |
| Handoff link lifetime | 1–90 days (default 30) |

A body over its limit gets `413` [`body_too_large`](https://punchlineanalytics.com/docs/api-reference#body-too-large).

## Privacy thresholds

Everything outside your own dashboard (the API, MCP, exports, warehouse tables and share links) follows the same rules:

- **Groups with fewer than 5 people's page views are withheld** and counted in `meta.suppressed_rows` (or `withheld_people` in reports). This stops a narrow query from describing one person.
- **Sessions of people are never returned.** Agent and crawler sessions are, in full.
- **Agent-only groups are always shown**, however small.

If a number you expect is missing, the group was probably small. Drop a dimension or widen the date range, and the people are counted in the larger group.

## Versioning

The API is versioned in the path (`/v1`). Within v1 we add endpoints, fields and dimension values without notice, so ignore fields you don't recognize and don't fail on new enum values. Anything that would break existing code goes in a new version. Changes are listed in the [changelog](https://punchlineanalytics.com/docs/changelog), and the [OpenAPI spec](https://punchlineanalytics.com/v1/openapi.json) always matches the API that's running.
