---
title: "Client events"
---

# Client events

How the Editor, the Agents app and the CLI record what a signed-in person did in the same chained audit ledger, with identity from the token, strict schema, idempotency and limits.

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

1. A **signed-in person**. API keys are refused (`403 api_key_not_allowed`).
2. With a **verified OAuth client**: the `client_id` claim of the access token. A token without one is refused.
3. That client must be a **registered first-party client** (`oauth2_clients.first_party`, which only a super admin can set): `403 client_not_first_party` otherwise. 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

```http
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:

```json
{"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.
