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:
curl -X POST https://api.activitylog.com/api/v1/auth/login \
-H "Content-Type: application/json" \
-d '{"email":"you@example.com","password":"..."}'
Response:
{
"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.
List messages
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
{
"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
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
curl "https://api.activitylog.com/api/v1/messages/aggregate?groupBy=type&from=2026-05-01" \
-H "Authorization: Bearer YOUR_JWT_HERE"
Response:
{
"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:
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.
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
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:
{ "exportId": "exp_01JT...", "status": "queued" }
Poll
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:
{
"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:
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
fromon big queries. Withoutfrom, you scan to the start of your retention window — slow and expensive. A 7-day window keeps query latency under 200ms. - Use
include=metadatafor aggregation. If you're not displaying message bodies, don't fetch them. - Prefer
aggregateover 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 |
| Build a dashboard | 90-reports.md |
| Operator-side query | 80-admin-portal.md |
| Formal endpoint contract | ../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.
If something you need isn't here, file a ticket.