The first-party apps call your Govern installation directly. POST /api/v1/audit/client-events lets a signed-in person's app record what that person did (opened a file, started an agent run, ran a command) in the same HMAC-chained audit ledger as everything Govern records itself.
These entries are attested by the app, not proven by the server: they show what the person's first-party app reported, under that person's identity. Anything that must hold is enforced by Govern's own gateways, scopes and the entries Govern writes itself. A modified app can misreport its own events, but it cannot lie about who it is, in which organisation, or in whose name.
Who may call it
- A signed-in person. API keys are refused (
403 api_key_not_allowed). - With a verified OAuth client: the
client_idclaim of the access token. A token without one is refused. - That client must be a registered first-party client (
oauth2_clients.first_party, which only a super admin can set):403 client_not_first_partyotherwise. This is checked on every request.
Everything about the caller comes from the token: organisation, user, client. A body that names another organisation or user is refused as a whole with 403 not_own_identity, and nothing is recorded.
Request
POST /api/v1/audit/client-events
Authorization: Bearer <access token of a signed-in person>
Content-Type: application/json
X-Keystone-Client: neuralinverse-editor/1.4.0
{"events": [{
"client_event_id": "0b0e8a2c-5f0a-4f0e-9c52-6a3b9f1e7d10",
"type": "editor.file.opened",
"occurred_at": "2026-10-16T11:59:12.431Z",
"outcome": "success",
"session_id": "sess_01J8",
"resource_type": "file",
"resource_id": "src/main.go",
"metadata": {"language": "go", "lines": "412"}
}]}
| Field | Rule |
|---|---|
client_event_id | Required. A UUID or ULID, stored in canonical form |
type | Required. A dotted namespace of 2 to 5 segments, each [a-z][a-z0-9_]{0,31}, at most 96 characters, for example editor.file.opened, agents.run.started, cli.command.executed |
occurred_at | Required. RFC 3339 with a zone, at most 7 days in the past and 5 minutes in the future |
outcome | success (default), failure, denied or error |
session_id | Optional, 1 to 128 characters of A-Za-z0-9._:~- |
resource_type, resource_id | Both or neither; type lower case, id at most 256 bytes |
metadata | Optional object of at most 32 members; values are strings only, at most 512 bytes each and 8 KiB in total |
Unknown members are refused, so a client cannot send server-owned fields. Text must be clean UTF-8 without control, format (bidirectional, zero-width) or separator characters, so a value cannot disguise or split an audit line.
Response
200 for every request that was understood, with one result per event in order:
{"results": [
{"index": 0, "client_event_id": "0b0e8a2c-...", "status": "accepted", "id": "81234", "duplicate": false, "recorded_at": "2026-10-16T12:00:01.2Z"},
{"index": 1, "client_event_id": "5c1d...", "status": "duplicate", "id": "80990", "duplicate": true, "recorded_at": "2026-10-16T11:58:40Z"},
{"index": 2, "client_event_id": "9f2e...", "status": "rejected", "duplicate": false,
"error": {"code": "invalid_type", "message": "..."}}],
"accepted": 1, "duplicates": 1, "rejected": 1,
"client": {"id": "ni_0d6b...", "name": "neuralinverse-editor", "version": "1.4.0"}}
A client drops from its buffer every event that is accepted or duplicate, and drops a rejected event (it will never be accepted as sent). Anything other than 200 recorded nothing and is retried unchanged.
| Status | Code | Meaning |
|---|---|---|
400 | invalid_request | Not one JSON object {"events": [...]} |
401 | unauthorized | No signed-in principal |
403 | api_key_not_allowed, client_not_first_party, not_own_identity | Admission refused |
413 | payload_too_large, batch_too_large | Over the body or batch limit |
415 | content_type | Not application/json |
429 | rate_limited, quota_exceeded | A minute limit or daily quota; Retry-After given |
503 | dlp_unavailable | The DLP patterns cannot be loaded; nothing is recorded unscanned |
500 | internal_error | The audit write failed; nothing is recorded |
Per-event rejection codes: invalid_event, invalid_client_event_id, invalid_type, invalid_occurred_at, occurred_at_out_of_range, invalid_outcome, invalid_session_id, invalid_resource, invalid_metadata, dlp_blocked.
What is written
One ledger entry per accepted event, in the organisation's chain, so exports, tiering, the immutability trigger and keystone-audit-verify cover it unchanged.
| Entry field | Value |
|---|---|
| Organisation, actor | The token's organisation and user, actor type user |
action | client. followed by the type, always; no client can produce an action Govern writes itself |
resource_type, resource_id | client_event and the event id |
outcome | success, or failure for failure, denied and error (the finer value is kept in metadata) |
| Time | The server's time of recording; the client's claim is kept as occurred_at |
metadata | source: "client", the verified oauth_client_id, the declared name and version, the event id, type, claimed time, outcome, session, resource, and the scrubbed attributes |
Find them with GET /api/v1/audit-logs?action_prefix=client..
Idempotency
The idempotency key is a hash of (OAuth client, user, event id), scoped to the organisation. A retry, in any spelling of the same id, returns the original entry's id and time and is not appended again. The same id from another user, client or organisation is a different event and never reveals or overwrites another person's record.
POST /api/v1/agents/audit has the same guarantee for agents when a report carries metadata.client_event_id.
DLP
Event text is scanned with the organisation's DLP patterns before it is written. A secret in a metadata value is redacted in place and counted (dlp_redactions); a finding in session_id or resource_id rejects that event with dlp_blocked.
Limits
Every event of a request counts when it is received, accepted or not. Counters live in PostgreSQL, so every replica sees the same count.
| Limit | Default | Variable |
|---|---|---|
| Events per request | 100 | NI_AUTH_CLIENT_EVENTS_MAX_BATCH |
| Request body | 256 KiB | NI_AUTH_CLIENT_EVENTS_MAX_BODY_BYTES |
Oldest occurred_at | 7 days | NI_AUTH_CLIENT_EVENTS_MAX_AGE |
Furthest occurred_at ahead | 5 minutes | NI_AUTH_CLIENT_EVENTS_MAX_FUTURE |
| Events per user per UTC minute | 600 | NI_AUTH_CLIENT_EVENTS_USER_PER_MINUTE |
| Events per organisation per UTC minute | 6,000 | NI_AUTH_CLIENT_EVENTS_ORG_PER_MINUTE |
| Events per user per UTC day | 50,000 | NI_AUTH_CLIENT_EVENTS_USER_PER_DAY |
| Events per organisation per UTC day | 2,000,000 | NI_AUTH_CLIENT_EVENTS_ORG_PER_DAY |
The server refuses to start when a value is unparsable or inconsistent (for example a per-minute user limit below one full batch). The limits are per person, not per app.