# Decision audit logs

Send `X-PIT-Audit: on` with a Desk key to record every `/v1` response. Each entry records the timestamp, the API key, the route, a query digest, the corpus version, and the SHA-256 hash of the response bytes. The system does not store the response body.

Reference page. Updated 2026-08-27. Desk plan only.

## Turning it on

- **Header**: Set `X-PIT-Audit: on`. You can also use `1`, `true`, or `yes`. Header names are case-insensitive.
- **Plan**: Requires a Desk plan. Requests using a Researcher or Power key work normally, but the system does not record them.
- **Scope**: Send the header on each request you want to log. No other configuration is needed for a Desk key.

If a request does not include the header, the server checks only the header and does not look up the key.

## What a receipt holds

| Field | What it is |
| --- | --- |
| `ts` | When the response finished. RFC3339 UTC with a literal `Z`, to the millisecond. |
| `key_id` | The API key that asked. The same id `GET /v1/keys` lists. |
| `route` | Method and path, such as `GET /v1/news`. The query string is not here. |
| `params_digest` | SHA-256 of the query string in a canonical form, 64 hex characters. Two calls asking the same thing match; the values themselves are not stored. |
| `corpus_version` | The corpus that answered, from the `X-Corpus-Version` header. JSON null when the call was refused before a corpus was consulted. |
| `envelope_sha256` | SHA-256 of the response body, byte for byte as you received it. |
| `request_id` | The `X-Request-Id` of that response, so a receipt and a support question point at the same call. |

A query against a given corpus_version returns the same data every time because the corpus is content-addressed. The envelope hash still differs between two identical queries because every response carries its own request_id. This makes the hash a receipt that proves which exact bytes one caller received.

## Checking a receipt

Hash your response bytes with SHA-256 and compare the result to `envelope_sha256`. Include the trailing newline, which `curl` passes through by default.

**terminal**

```
$ curl -s -H "Authorization: Bearer pit_live_..." -H 'X-PIT-Audit: on' \
    'https://api.pit.aqx.llc/v1/news?ticker=SIVB&as_of=2023-03-10T23:59:59Z&limit=2' \
    | shasum -a 256
```

The resulting hex string matches `envelope_sha256` in the receipt. Refused calls are also logged. For example, a 409 response for a coverage hole produces a receipt with a null `corpus_version` because no corpus answered the request.

## Reproducing params_digest

To calculate `params_digest`: parse the query string, sort each parameter's values, sort the parameters by name, and re-encode them as percent-encoded `k=v` pairs joined by `&`. Then compute the SHA-256 hash of that string. If a request has no query string, hash the empty string.

**python**

```
import hashlib, urllib.parse

def params_digest(query):
    pairs = urllib.parse.parse_qs(query, keep_blank_values=True)
    canon = urllib.parse.urlencode(
        sorted((k, v) for k, vs in pairs.items() for v in sorted(vs)))
    return hashlib.sha256(canon.encode()).hexdigest()
```

`params_digest("ticker=SIVB&as_of=2023-03-10T23:59:59Z&limit=2")` is `701c3eae4f1dbcfaf5147f12dc07d0526f40680265498eb1ef170d0770317851`.

## Reading your log

**terminal — 200 · newest last**

```
$ curl -s -H "Authorization: Bearer pit_live_..." \
    'https://api.pit.aqx.llc/v1/audit-log?day=2026-08-27&limit=100'
```

The response uses the [standard envelope](https://pit.aqx.llc/docs/api) with one receipt per item in `results`. Receipts are scoped to your account.

| Parameter | What it does |
| --- | --- |
| `day` | One UTC calendar day, `YYYY-MM-DD`. Omit for every day. |
| `limit` | 1 to 1000, default 100. A value outside that range returns 400. |
| `cursor` | `next_cursor` from the previous page. Send the same `day` with it. |
| `format` | `json` (default) or `jsonl`. |

Pages are ordered chronologically by `ts`. The log is append-only, so existing pages never change. When you resume pagination, you receive only newer entries.

## Exporting

Use `format=jsonl` to receive raw `application/x-ndjson` with one receipt per line and no outer envelope. If more pages exist, the next token is in the `X-PIT-Audit-Next-Cursor` response header.

**terminal**

```
$ curl -s -H "Authorization: Bearer pit_live_..." \
    'https://api.pit.aqx.llc/v1/audit-log?format=jsonl&day=2026-08-27&limit=1000' \
    > receipts-2026-08-27.jsonl
```

## Knowing the log is complete

Receipts are written asynchronously after each response using a fixed-depth queue. If the queue is full, the server drops the receipt so the request is not delayed. Dropped receipts are counted and exposed in two places.

The `X-PIT-Audit-Dropped` header is included on all audited responses and all `/v1/audit-log` responses. It shows the total number of receipts the server process has dropped since startup. If the value does not change during a run, no receipts were lost.

**terminal**

```
$ curl -s -H "Authorization: Bearer pit_live_..." \
    https://api.pit.aqx.llc/v1/audit-log/stats
```

| Field | What it counts |
| --- | --- |
| `receipts` | Receipts stored for your account. `day_receipts` is the same count for a `day` you name. |
| `lost` | Receipts owed and not held: `queue.dropped` plus `queue.failed`. |
| `queue.requested` | Calls that asked to be receipted. |
| `queue.unattributed` | Asked, but had no valid API key, so there was no key to record. |
| `queue.plan_required` | Asked with a key that is not on Desk. |
| `queue.skipped` | Asked, but the response was not a JSON envelope. |
| `queue.enqueued` | Accepted into the queue. `written` reached the database, `failed` did not. |
| `queue.dropped` | Refused because the queue was full. |
| `queue.queued` | Waiting right now, out of `capacity`. |

The `queue` metrics cover the entire server process rather than a single key, because dropped receipts are not attributed to a key ID. The default queue depth is 1200, which holds one full minute of requests at the Desk rate limit.

## What is not recorded

| Case | Why |
| --- | --- |
| No `X-PIT-Audit` header | Receipting is per request. |
| Paths outside `/v1` | Only `/v1` routes are receipted. `/health`, the website and `POST /t` sit outside. |
| A key on another plan | Counted under `plan_required`. |
| A rejected key or a browser session | Counted under `unattributed`: a receipt records a key id, and there is none to record. |
| Replies that are not JSON | The OpenAPI document, the `/v1/stream` event stream, a 304 whose body you already hold. Counted under `skipped`. |
| Reading the log itself | Excluded so that reading the log does not add to it. |
| MCP tool calls | The `/mcp` endpoint does not pass the header through yet. |

## Retention

The retention policy for Desk receipts is 400 days. This limit is currently not enforced, and receipts are kept indefinitely. Use `format=jsonl` to export and store receipts long term.

## Limits

`/v1/audit-log` and `/v1/audit-log/stats` allow 60 requests per minute per account. This limit is separate from the standard `/v1` limit on your plan. Requests above this limit return a 429 status code with a `Retry-After` header.

These two routes are not yet in `openapi.json`, so generated SDK clients do not include them. You must call them directly. The [dashboard](https://pit.aqx.llc/dashboard) does not display receipts.
