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 |
|---|---|
POST /api/v1/activity/search |
Faceted cross-source search — events + partner/person/action facet counts |
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.
Faceted activity search — POST /api/v1/activity/search
The cross-source faceted query. Where /messages lists raw events from one stream, /activity/search runs over the ClickHouse facet index and returns matching events plus facet roll-ups (counts grouped by any facet — source, person, action, or a tenant-defined facet) in a single call. It's facet-generic: you filter and group by facet keys, so it works the same for every tenant regardless of which facets they've configured. This is the endpoint external apps — e.g. the Knowledge Base — use to pull one person's or one partner's activity across every connected system.
Auth: a user JWT with Reader role on the tenant (same POST /auth/login flow as above). System tokens are ingest-only and get 403 here. For a machine caller, provision a dedicated Reader service user and log in with its credentials.
Request
{
"tenantId": "5e42dbea-fa11-4122-b88e-c54f4c74802d",
"where": {
"person": "dan@fyin.com",
"partner": ["AdvoTrac", "NewRez"],
"source": "umbraco-kb"
},
"from": "2026-01-01T00:00:00Z",
"to": "2026-07-01T00:00:00Z",
"groupBy": ["partner", "action"],
"includeBody": false,
"limit": 50,
"cursor": null
}
| Field | Type | Notes |
|---|---|---|
tenantId |
GUID (required) | Tenant to scope the search to. Caller needs Reader on it. |
where |
object | Generic facet map — facetKey → value \| [values]. A string means equals; an array means IN (OR within one key). Multiple keys AND together. Keys can be any facet in your tenant's catalog, plus the universal source and person. |
where.person |
string | string[] | Special-cased: matches actor email(s), resolved to the internal person id. |
from / to |
RFC3339 | Inclusive event-time window. Time is a range, so it's top-level, not inside where. |
groupBy |
string[] | Any facet key(s) to roll up — universal (source, person, date) or any facet in your catalog. Unknown keys are skipped with a warning. |
includeBody |
bool | Include the full raw event body per document. Default false (keeps payloads lean). |
limit |
int | Page size. Default 200, capped at 1000. |
cursor |
string | Keyset cursor from a prior response's nextCursor. |
All where clauses AND together. The valid facet keys are your tenant's — the universal source/person/date always work; everything else (action, partner, and any facet you've defined) comes from your facet catalog. Discover them with GET /api/v1/activity/capabilities?tenantId=…. Adding a facet needs no API change — once published it is queryable here.
The report facets are ordinary facets, so the same counts the reports show are one call away, for example unapproved changes per repository:
{ "tenantId": "…", "where": { "change_state": ["unapproved", "direct_push"] },
"from": "2026-09-01T00:00:00Z", "to": "2026-10-01T00:00:00Z", "groupBy": ["repo"], "limit": 1 }
Activity with no value for a grouped facet is returned under an empty key.
Response
{
"documents": [
{
"ts": "2026-06-30T14:02:11.512Z",
"messageId": "2152135693029037468911937261113800096",
"personId": null,
"personDisplay": "Dan Bartels",
"source": "umbraco-kb",
"type": "kb.content.viewed",
"action": "view",
"partnerName": "AdvoTrac",
"projectName": null
}
],
"facets": {
"partner": { "AdvoTrac": 466183, "FYIN": 392529, "PNFP": 194565 },
"action": { "view": 2007422, "create": 523013, "edit": 262243 }
},
"nextCursor": "MTUwODg0Mjc2ODQ1MDoy...",
"warnings": []
}
documents— one row per event ("a dot"), ordered by(ts, messageId)ascending.partnerNameandpersonDisplayare resolved facets from the index.bodyis present only whenincludeBody: true.facets— present only whengroupByis set. Each key maps facet value → count across the entire filtered set, not just the current page.nextCursor— non-null when more rows exist; pass it back ascursorfor the next page. Keyset pagination is stable under concurrent inserts.warnings— non-fatal notes (e.g. awhere.personemail that matches nobody returns an empty result with a warning, not an error).
Example — one partner's activity, grouped by action
curl -X POST https://api.activitylog.com/api/v1/activity/search \
-H "Authorization: Bearer YOUR_JWT_HERE" \
-H "Content-Type: application/json" \
-d '{
"tenantId": "5e42dbea-...",
"where": { "partner": "AdvoTrac" },
"from": "2026-06-01T00:00:00Z",
"to": "2026-07-01T00:00:00Z",
"groupBy": ["action"],
"limit": 100
}'
Page through large result sets by echoing nextCursor back as cursor until it returns null.
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) |
system |
name or id | ?system=billing-service (a name matches that system in your tenant) |
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 per time bucket, grouped by a field
curl "https://api.activitylog.com/api/v1/messages/aggregate?groupBy=type&bucket=day&from=2026-05-01" \
-H "Authorization: Bearer YOUR_JWT_HERE"
Response:
{
"bucket": "day",
"groupBy": "type",
"from": "2026-05-01T00:00:00Z",
"to": null,
"buckets": [
{
"at": "2026-05-01T00:00:00Z",
"groups": [
{ "key": "user.signed_in", "count": 1402 },
{ "key": "order.placed", "count": 517 }
]
}
]
}
| Parameter | Values |
|---|---|
groupBy |
type (default), stream, system (the system id), level |
bucket |
hour, day (default), week |
system, stream, type |
Narrow the set first (system takes a name or an id) |
from / to |
Ingest-time range |
aggregate returns counts only. For sums (cost, tokens) or grouping by any facet — person, partner, project, change_state, exposure and the rest — use POST /api/v1/activity/search with groupBy, or the report pages, which are built on it.
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.