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 fromto 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 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
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.