---
title: "Search audit logs"
method: POST
path: "/api/v1/audit/search"
tags: ["Audit"]
---

# Search audit logs

`POST /api/v1/audit/search`

## Request

- Base URL: `https://orchestrator.getaxonflow.com`
- URL: `https://orchestrator.getaxonflow.com/api/v1/audit/search`
- Auth: none declared. Required headers: `X-Tenant-ID`
- TypeScript: <https://skmtc.dev/getaxonflow/apis/axonflow-orchestrator-api/docs/api/v1/audit/search/post.ts> (a function with typed inputs; its response is checked against the schema)

Search audit logs by various criteria. The tenant scope is always forced
from the `X-Tenant-ID` header (the body cannot override it); requests
without the header are rejected with 401. `user_email` and `client_id`
are case-insensitive partial (ILIKE substring) matches. The search
start time is clamped to the tenant's tier-based retention window.

**Role-scoped reads (#2922):** the caller's read scope is resolved
server-side. `admin`/`owner` read the full tenant trail; every other
role — and any caller without a validated per-user identity — reads
**only their own** `user_email` rows (fail-closed). A non-admin's
`user_email` filter can only narrow the result to their own identity,
never widen it to another user's rows. Callers without any resolvable
identity receive an empty `entries` array. The role/scope is trusted
only over the internal agent→orchestrator proxy-auth channel, never a
client-forwarded header.

**Single-operator deployments (#3060):** `DEPLOYMENT_MODE=community`
reads tenant-wide unconditionally, and `DEPLOYMENT_MODE=community-saas`
reads tenant-wide for requests that arrived over the agent gateway
(proven by the internal proxy-auth token) — in that mode the
organization, tenant and credential are one `cs_<uuid>`, so tenant-wide
is that single evaluator's own data. A community-saas request that
reaches the orchestrator directly stays least-privilege. Read scope is
a separate axis from administrative authority: a community-saas caller
reads tenant-wide here and is still denied (403) the whole-tenant
compliance exports and the cost/usage/execution family.

**Method:** this endpoint is **POST-only** (its criteria are a JSON
body). A `GET` returns **405** with `Allow: POST, OPTIONS`.

## Headers

- `X-Tenant-ID` string, required

## Request body

- AuditSearchRequest: Search filters. The tenant scope is NOT part of this body — it is always forced from the X-Tenant-ID header and cannot be overridden by the payload.
  - `user_email` string: Case-insensitive partial (ILIKE substring) match
  - `client_id` string: Case-insensitive partial (ILIKE substring) match
  - `start_time` string, date-time: Window lower bound; clamped to the tenant's tier-based retention cutoff when earlier (or when omitted)
  - `end_time` string, date-time
  - `action` string: Filter by policy decision. The value is normalized to its canonical verdict (allowed, blocked, redacted, needs_approval, error) and expanded to every historical DB spelling of that verdict, so it matches both current and legacy rows.
  - `session_id` string: Exact match on the first-class session_id column — used to drill into a session-summary bucket's raw events (#2857).
  - `limit` integer
  - `offset` integer: Pagination offset — number of audit-log rows to skip from the start of the result set. Pair with `limit` to walk multi-page audit reads.
  - `decision_id` string: Filter audit reads to a specific governance decision id (mints from MCPCheckInputResponse / MCPCheckOutputResponse etc.). Matches `policy_details->>'decision_id'`. Useful when correlating a specific request through its full audit trail.
  - `override_id` string: Filter to audit entries that recorded an override-used event for this override id (matches `policy_details->>'override_id'`).
  - `policy_name` string: Filter to audit entries where this policy fired. Matches the three shapes audit writers store in the policy_details JSONB: the scalar `policy_details.policy_name`, the CSV string `policy_details.policy_names`, and `policy_details.policy_matches[*].policy_name` (workflow step gates + decision records).

## Response `200`

Audit search results. `total` is the true pre-LIMIT match count
for the filters, so callers can paginate with `limit`/`offset`.
`entries` is always a JSON array (`[]` when empty, never `null`).

- AuditSearchResponse: Paginated audit search results
  - `entries` AuditLogEntry[]: Matching audit entries (always an array, `[]` when empty)
    - `id` string
    - `request_id` string
    - `timestamp` string, date-time
    - `user_id` integer
    - `user_email` string
    - `user_role` string
    - `client_id` string
    - `tenant_id` string
    - `org_id` string
    - `request_type` string
    - `query` string: The audited query/prompt (already redacted by the write path)
    - `query_hash` string
    - `policy_decision` string: Verdict for the request. Canonical values are allowed, blocked, redacted, needs_approval, error; historical rows may carry legacy spellings.
    - `policy_details` object: Nested decision detail exactly as the writer stored it (policy_ids / reasons / latency_ms plus writer-specific keys like gateway_id, tool_name, decision_id, override_id, policy_matches). Treat keys as writer-specific.
    - `provider` string
    - `model` string: LLM model identifier the request was routed to (e.g. `gpt-4o-mini`, `llama3.2:latest`). Surfaced separately from `provider` so callers can filter audit reads by model without parsing the provider's vendor-specific naming.
    - `response_time_ms` integer: Measured duration for this row in whole milliseconds. ABSENT when this row's writer measured nothing (#3424). Absent is NOT `0`. This field used to be emitted unconditionally, so every row from a writer with no duration to record -- blocked request / response / media, failed request, workflow, plan and tool-call rows, HITL approvals, and the connector-exec MCP closure -- carried a literal `0`, which the portal's Latency column rendered as a confident "0ms". The key is now omitted for those rows rather than set to null: this schema declares no `required` list, so the property was already optional and a conforming client already handles its absence. Nothing about the declared contract changes. A `0` that DOES arrive is a real measurement: the column is whole milliseconds, so a decision the platform timed at under 1ms is stored as the 0 its clock produced. The portal renders that as "<1ms" and an absent value as "-". The quantity differs by `plane`: enforcement duration on decision/gateway/mcp/openai_compat, a PROVIDER round trip on `llm`, and a client-asserted OTLP duration on cowork/claude_code (#3431). The compliance summary's `avg_latency_ms` averages only the first.
    - `tokens_used` integer: Provider tokens consumed by this request, OMITTED when the row carries no RECORDED provider usage (#3427). The omission says the usage was not recorded; it does NOT assert that no provider was called. Two distinct populations omit it: * Rows whose writer never reached a model at all -- a blocked request, a redaction, a gateway pre-check deny, a media deny, a workflow step, a plan or a tool-call row. * Rows written by the LLM RESPONSE-plane block writer, which runs AFTER the forward: the call was made and the answer was withheld, so a real round trip was paid for, but that writer records none of the provider usage it is handed. That is a gap in the writer, tracked separately, not a claim about the request. Until #3427 both populations left the read paths as a literal `0`, which the portal's detail panel rendered as "Tokens 0" under a request no model saw. The key is now omitted for those rows rather than set to null: this schema declares no `required` list, so the property was already optional and a conforming client already handles its absence. A `0` that DOES arrive is a real report from a provider, not an absence.
    - `cost` number, double: Estimated provider cost for this request in USD, OMITTED when the row carries no recorded provider usage (#3427). Same rule, same two populations and same rationale as `tokens_used` above; note that `0` is a genuinely representable cost (a locally hosted or free-tier model), which is why absence is expressed by omitting the key rather than by a zero.
    - `redacted_fields` string[]
    - `error_message` string: Omitted when empty
    - `response_sample` string
    - `compliance_flags` string[]
    - `security_metrics` object
    - `decision_id` string: Canonical decision-row id (#2597/ADR-058); present on planes that mint decisions, omitted on legacy writers
    - `plane` string: Enforcement plane that wrote the row (e.g. mcp, llm); omitted on legacy writers. The orchestrator's four ENFORCEMENT planes are `orchestrator_request` for /api/v1/process and /api/v1/plan/execute, `wcp` for the workflow step gate, `map` for a multi-agent step and `orchestrator_response` for the LLM response. (`policy_simulation` and `policy_test` are the orchestrator's too, but they are operator tools rather than enforcement points and write no decision row.)
    - `correlation_id` string: Decision-chain correlation key (#2611); omitted when not stitched
    - `transfer_basis` string: UU PDP Pasal 56 cross-border transfer legal basis (adequacy, safeguards, pasal_56b_dpa, consent) — Enterprise LLM-forward path only (#2718)
    - `data_residency` string: ISO 3166-1 alpha-2 destination country (#2718); Enterprise only
    - `session_id` string: AI-tool session id (Claude Code / Desktop) forwarded via X-Session-Id; asserted attribution, not an auth boundary
  - `total` integer: True pre-LIMIT match count for the filters (for pagination)
  - `limit` integer
  - `offset` integer

## Other responses

- `400`: Invalid request body
- `401`: Missing X-Tenant-ID header (tenant scoping required)
- `500`: Audit search failed

---

[API](https://skmtc.dev/getaxonflow/apis/axonflow-orchestrator-api.md) - [All operations](https://skmtc.dev/getaxonflow/apis/axonflow-orchestrator-api/llms.txt) - [OpenAPI document](https://skmtc.dev/getaxonflow/apis/axonflow-orchestrator-api/revisions/f408ef3ec9dd?raw)
