# Query API

Read messages back from ActivityLog. All query endpoints require a **user JWT** (not a system token) — sign in via the portal or `POST /auth/login` and use the returned token.

System tokens are write-only; they cannot query.

## Endpoints at a glance

| Endpoint | Returns |
|---|---|
| `GET /api/v1/messages` | Paged list with filters |
| `GET /api/v1/messages/{id}` | Single message detail |
| `GET /api/v1/messages/aggregate` | Counts grouped by a field |
| `GET /api/v1/messages/timeline` | Time-bucketed counts (for the activity feed) |
| `POST /api/v1/exports` | Kick off an async export job |

## Authenticate

Sign in via the portal or call:

```bash
curl -X POST https://api.activitylog.com/api/v1/auth/login \
  -H "Content-Type: application/json" \
  -d '{"email":"you@example.com","password":"..."}'
```

Response:

```json
{
  "accessToken":  "eyJhbGc...",
  "refreshToken": "...",
  "expiresAt":    "2026-05-20T15:21:03Z"
}
```

The access token lasts 1 hour. Refresh via `POST /api/v1/auth/refresh` with the refresh token. Tighter session security applies to admin (operator) users — see [`80-admin-portal.md`](./80-admin-portal.md).

## List messages

```bash
curl "https://api.activitylog.com/api/v1/messages?stream=events&take=50" \
  -H "Authorization: Bearer YOUR_JWT_HERE"
```

Returns the most recent 50 by default, newest first.

### Filters

| Parameter | Type | Example |
|---|---|---|
| `stream` | string (exact) | `?stream=events` |
| `level` | enum | `?level=error` |
| `type` | string (exact) | `?type=user.signed_in` |
| `tag` | string | `?tag=prod` (multiple `tag` params ORed) |
| `meta.<key>` | string | `?meta.partner=acme&meta.region=us-east` (ANDed) |
| `systemId` | ULID | `?systemId=01JT...` |
| `from` | RFC3339 | `?from=2026-05-01T00:00:00Z` (inclusive) |
| `to` | RFC3339 | `?to=2026-05-20T00:00:00Z` (inclusive) |
| `skip` | int | `?skip=50` |
| `take` | int | `?take=200` (max 200) |

Filters combine: `?stream=events&level=error&meta.partner=acme&from=2026-05-01T00:00:00Z`.

### Response

```json
{
  "total": 1,
  "skip":  0,
  "take":  50,
  "items": [
    {
      "id":             "01JT8KMBN4VK0000000000000",
      "tenantId":       "01JT...",
      "systemId":       "01JT...",
      "stream":         "events",
      "level":          "info",
      "type":           "order.placed",
      "timestamp":      "2026-05-10T09:15:42.100Z",
      "ingestedAt":     "2026-05-10T09:15:42.123Z",
      "body":           "{\"orderId\":\"ord_99\",\"total\":49.99}",
      "sizeBytes":      34,
      "tags":           ["prod"],
      "metadata":       { "region": "us-east", "partner": "acme" },
      "durationStatus": null,
      "sourceEventId":  null
    }
  ]
}
```

`total` is the total matching count (across all pages). Use `skip`/`take` for pagination.

## Single message detail

```bash
curl "https://api.activitylog.com/api/v1/messages/01JT8KMBN4VK0000000000000" \
  -H "Authorization: Bearer YOUR_JWT_HERE"
```

Returns the same shape as a list item. 404 if not found or belongs to a different tenant.

## Aggregate — counts grouped by a field

```bash
curl "https://api.activitylog.com/api/v1/messages/aggregate?groupBy=type&from=2026-05-01" \
  -H "Authorization: Bearer YOUR_JWT_HERE"
```

Response:

```json
{
  "groupBy": "type",
  "buckets": [
    { "key": "user.signed_in",  "count": 1402 },
    { "key": "order.placed",    "count":  517 },
    { "key": "error.unhandled", "count":    3 }
  ]
}
```

### Group-by fields

| Field | Notes |
|---|---|
| `type` | Event type |
| `stream` | Logical channel |
| `level` | Severity |
| `systemId` | Source system |
| `metadata.<key>` | Any metadata key (e.g. `metadata.partner`) |

### Metrics

By default `aggregate` returns counts. For LLM cost / token sums use the `metric` parameter:

| Metric | What it does |
|---|---|
| `count` (default) | Number of matching messages |
| `sum(costMicrocents)` | Total cost across matches |
| `sum(inputTokens)` | Total input tokens |
| `sum(outputTokens)` | Total output tokens |
| `avg(durationMs)` | Average duration for closed duration messages |

Example:

```bash
curl "https://api.activitylog.com/api/v1/messages/aggregate?type=ai.generation&groupBy=model&metric=sum(costMicrocents)&from=2026-05-01" \
  -H "Authorization: Bearer YOUR_JWT_HERE"
```

## Timeline — time-bucketed counts

Used by the activity-dots chart in the portal. Returns bucket counts per time interval.

```bash
curl "https://api.activitylog.com/api/v1/messages/timeline?bucketSize=1h&from=2026-05-19T00:00:00Z&to=2026-05-20T00:00:00Z" \
  -H "Authorization: Bearer YOUR_JWT_HERE"
```

`bucketSize` accepts `1m`, `5m`, `15m`, `1h`, `1d`. The buckets returned cover the `from`–`to` range; empty buckets are returned as `count: 0` so the chart doesn't need to fill gaps.

## Exports

For full data extraction (compliance, off-platform analysis), exports run async.

### Kick off

```bash
curl -X POST https://api.activitylog.com/api/v1/exports \
  -H "Authorization: Bearer YOUR_JWT_HERE" \
  -H "Content-Type: application/json" \
  -d '{
    "format": "jsonl",
    "filter": {
      "from":   "2026-05-01T00:00:00Z",
      "to":     "2026-05-20T00:00:00Z",
      "stream": "events"
    }
  }'
```

Response:

```json
{ "exportId": "exp_01JT...", "status": "queued" }
```

### Poll

```bash
curl "https://api.activitylog.com/api/v1/exports/exp_01JT..." \
  -H "Authorization: Bearer YOUR_JWT_HERE"
```

When `status` becomes `completed`, the response includes a one-time signed download URL valid for 1 hour:

```json
{
  "exportId":    "exp_01JT...",
  "status":      "completed",
  "downloadUrl": "https://storage.activitylog.com/exports/...?sig=...",
  "expiresAt":   "2026-05-20T16:21:03Z",
  "rows":        47218,
  "format":      "jsonl"
}
```

### Format

The bundle is a `.activitylog-export` archive — JSONL of all matching messages plus a `manifest.json` with row counts, checksums, and the originating filter. This is the canonical "take your data with you" format.

### Limits

| Tier | Concurrent exports | Max rows per export | Retention of completed archives |
|---|---|---|---|
| Free | — | export not available | n/a |
| Pro | 2 | 1,000,000 | 7 days |
| Enterprise | 10 | unlimited | 30 days |

## Body inclusion

By default, list responses include `body` for every message. For aggregate / timeline / very large list pages, omit the body:

```bash
curl "https://api.activitylog.com/api/v1/messages?stream=events&take=200&include=metadata" \
  -H "Authorization: Bearer YOUR_JWT_HERE"
```

`include=metadata` returns only the indexed columns + `metadata`, no `body`. Reduces payload by 90%+ when bodies are large.

## Errors

| HTTP | Slug | Meaning |
|---|---|---|
| 400 | (varies) | Invalid filter / param |
| 401 | `unauthenticated` | JWT missing or expired |
| 403 | `forbidden` | Trying to query someone else's data |
| 404 | `not-found` | Message id doesn't exist or belongs to a different tenant |
| 429 | `rate-limit` | Query rate limit exceeded (per-tenant) |

## Rate limits — query side

| Tier | Query rate (req/min) |
|---|---|
| Free | 60 |
| Pro | 600 |
| Enterprise | per contract |

`429` responses include `Retry-After`.

## Best practices

- **Always set `from` on big queries.** Without `from`, you scan to the start of your retention window — slow and expensive. A 7-day window keeps query latency under 200ms.
- **Use `include=metadata` for aggregation.** If you're not displaying message bodies, don't fetch them.
- **Prefer `aggregate` over fetching-and-counting.** A million-row count returned via list pagination is wasteful when the API can sum it in one query.
- **Cache the JWT in your client.** Sign-in is intentionally slow (Argon2id is CPU-expensive). Re-use the access token until it expires, then refresh.

## What's next

| Goal | Doc |
|---|---|
| Push more data in | [`50-ingest-native.md`](./50-ingest-native.md) |
| Build a dashboard | [`90-reports.md`](./90-reports.md) |
| Operator-side query | [`80-admin-portal.md`](./80-admin-portal.md) |
| Formal endpoint contract | [`../API-Reference.md`](../API-Reference.md) |

## Coverage gaps

The current API doesn't expose:
- **Full-text search on body content** above the tier window (Free: last 24h only; Pro: full retention; Enterprise: full retention with custom indexes). Tracked.
- **Saved queries / named filters.** Tracked.
- **SQL passthrough.** Coming for Enterprise as part of the Grafana-embedding story — see [`90-reports.md`](./90-reports.md).

If something you need isn't here, file a ticket.
