Querying Audit Records
All audit queries go through the Audit Service query layer, which
automatically applies FINAL to every ClickHouse SELECT. This
guarantees that anonymized records (GDPR) are never returned in their
original form via the API.
Get Record by ID
const record = await kernel.audit().get('01j9paud1000000000000000');
GET https://api.septemcore.com/v1/audit/01j9paud1000000000000000
Authorization: Bearer <access_token>
Response 200 OK:
{
"auditId": "01j9paud1000000000000000",
"action": "user.login",
"entityType": "user",
"entityId": "01j9pa5mz700000000000000",
"userId": "01j9pa5mz700000000000000",
"ip": "192.0.2.10",
"userAgent": "Mozilla/5.0 ...",
"before": null,
"after": { "loginAt": "2026-04-15T10:30:00.000Z", "method": "password" },
"metadata": { "sessionId": "01j9psess00000000000000" },
"timestamp": "2026-04-15T10:30:00.000Z"
}
Search Records
GET https://api.septemcore.com/v1/audit?action=user.login&userId=01j9pa5mz...&from=2026-04-01T00:00:00Z&to=2026-04-30T23:59:59Z&limit=20
Authorization: Bearer <access_token>
{
"data": [
{
"auditId": "01j9paud1000000000000000",
"action": "user.login",
"entityType": "user",
"entityId": "01j9pa5mz700000000000000",
"userId": "01j9pa5mz700000000000000",
"ip": "192.0.2.10",
"timestamp": "2026-04-15T10:30:00.000Z"
}
],
"pagination": {
"nextCursor": "01j9paud4000000000000000",
"hasMore": true
}
}
Filter Parameters
| Parameter | Description |
|---|---|
action | Filter by exact action string (e.g. money.transaction.debited) or prefix (e.g. money.) |
entityType | Filter by entity type (user, wallet, role, module, etc.) |
entityId | Filter by entity ID |
userId | Filter by user who performed the action |
from | Start of time range (ISO 8601, inclusive) |
to | End of time range (ISO 8601, inclusive) |
limit | Page size (default 20, max 100) |
cursor | Cursor from previous page's nextCursor |
Pagination Behavior
Audit records are sorted by timestamp DESC. Cursor-based pagination
is used rather than offset-based to avoid scanning large result sets
in ClickHouse. Cursor is an auditId (ULID) — the service uses it
as a WHERE timestamp < cursor_timestamp predicate on the index.
SDK:
const result = await kernel.audit().query({
action: 'money.transaction.debited',
from: '2026-04-01T00:00:00Z',
to: '2026-04-30T23:59:59Z',
limit: 50,
});
// result.data, result.pagination.nextCursor, result.pagination.hasMore
Entity History
Retrieve all audit records for a specific entity — a complete chronological history of everything that happened to it.
const history = await kernel.audit().entityHistory('wallet', '01j9paw1t000000000000000');
GET https://api.septemcore.com/v1/audit/entity/wallet/01j9paw1t000000000000000?limit=50
Authorization: Bearer <access_token>
{
"entityType": "wallet",
"entityId": "01j9paw1t000000000000000",
"data": [
{
"auditId": "01j9paud9000000000000000",
"action": "money.transaction.credited",
"userId": "01j9pa5mz700000000000000",
"before": { "available": 0 },
"after": { "available": 5000 },
"timestamp": "2026-04-15T10:30:00.000Z"
},
{
"auditId": "01j9paud8000000000000000",
"action": "money.hold.created",
"userId": "01j9pa5mz700000000000000",
"before": { "available": 5000, "frozen": 0 },
"after": { "available": 2500, "frozen": 2500 },
"timestamp": "2026-04-15T10:31:00.000Z"
}
],
"pagination": { "nextCursor": null, "hasMore": false }
}
This endpoint is optimized for compliance investigations: "show me
everything that happened to this wallet." It uses the ClickHouse
index (tenant_id, action, timestamp) with an entity_id filter,
which resolves in milliseconds for entities with up to millions of
records.
Export
Export audit records to JSON or CSV for compliance reports, legal requests, and external audit tools.
GET https://api.septemcore.com/v1/audit/export?format=csv&action=money.&from=2026-01-01T00:00:00Z&to=2026-03-31T23:59:59Z
Authorization: Bearer <access_token>
The export endpoint accepts the same filter parameters as
GET /audit. Additional parameters:
| Parameter | Description |
|---|---|
format | json (default) or csv |
Response for format=json: streaming application/json (newline-
delimited JSON, one record per line).
Response for format=csv: streaming text/csv with header row.
GDPR Export Guarantee
Before returning any records, the export endpoint checks for pending GDPR anonymization requests for the queried tenant. If any are pending, the endpoint waits for all anonymizations to complete before streaming the export:
GET /audit/export received:
1. Check: any anonymizing:{userId} Valkey locks for this tenant?
┌─ No pending → stream export immediately
└─ Pending found → wait for lock release (poll 100ms, max 5s)
→ stream export (anonymized version)
This guarantees that even a freshly submitted GDPR request is reflected in any export initiated within the same time window.
ReplacingMergeTree FINAL
All queries to ClickHouse from the Audit Service query layer use
the FINAL modifier:
SELECT * FROM audit_logs FINAL
WHERE tenant_id = $tenantId
AND action = $action
ORDER BY timestamp DESC
LIMIT 20;
Without FINAL: ClickHouse may return both the original record
and the GDPR-anonymized record simultaneously. The ReplacingMergeTree
engine merges duplicates in a background process (mergeTree), not
at write time. Until the merge runs, both versions coexist.
With FINAL: ClickHouse performs the deduplication at query
time, returning only the latest version — the anonymized one.
Without FINAL (race condition):
Query returns:
Row 1: { userId: "user-01j...", email: "[email protected]" } ← original
Row 2: { userId: "user-01j...", email: "[REDACTED]" } ← anonymized
With FINAL (correct):
Query returns:
Row 1: { userId: "user-01j...", email: "[REDACTED]" } ← latest only
Module developers must not query ClickHouse directly. Always
use kernel.audit() — the SDK adds FINAL automatically.
Error Reference
| Scenario | HTTP | Code |
|---|---|---|
| Record not found | 404 | not-found |
| Record belongs to different tenant | 403 | forbidden |
from / to not valid ISO 8601 | 400 | validation-error |
limit > 100 | 400 | validation-error |
format not json or csv | 400 | validation-error |
| Export pending anonymization timeout (> 5s) | 503 | ANONYMIZATION_PENDING — retry in 10s |