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. partnerName and personDisplay are resolved facets from the index. body is present only when includeBody: true.
  • facets — present only when groupBy is 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 as cursor for the next page. Keyset pagination is stable under concurrent inserts.
  • warnings — non-fatal notes (e.g. a where.person email 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 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.