# API log

> Every call your keys made, with the request and the response redacted, kept seven days.

Source: https://developer.mueslimail.com/docs/api-log/

When an agent calls the API and something goes wrong, the answer alone rarely says why. The API log keeps one row per authenticated call: the request id, the method, the path, the status, the duration, the key id and the SDK recognised from the `User-Agent`. Reading it needs `audit_read`, which a member never holds, so the log is an owner and admin surface.

> Careful: The log never keeps a message. A field that carried a subject, a body or an attachment stores only its length, its content type and a SHA-256 digest. Two calls with the same digest carried the same mail, and nothing of that mail is readable from the log.

## Read it

```bash
curl -s https://api.mueslimail.com/v0/logs?limit=20 \
  -H "Authorization: Bearer ***"
```

Filters: `key_id`, `method`, `status`, `sdk`, `after` and `before` (RFC 3339, `before` exclusive, which is how you page). `GET /v0/logs/{log}` returns the same row with `ip`, `user_agent` and the two redacted bodies. A row of another organization is `404`, never `403`.

## What is redacted, and how you can tell

* A credential field (`authorization`, `secret`, `api_key`, `token`, `password`, `cookie`) becomes `{"redacted": "credential"}`.
* A mail field (`subject`, `text`, `html`, `raw`, attachment `content`) becomes `{"redacted": "mail_content", "bytes": N, "content_type": "...", "sha256": "..."}`.
* A header outside the allowlist (`accept`, `content-type`, `idempotency-key`, `user-agent`, `x-mai-api-version`, `x-request-id`) is not kept at all, and its name is listed when it was a credential header.
* Anything that looks like a token, a `whsec_` signing secret or a bearer value is masked even inside a free text field.
* `redactions` names every removal, as `header:authorization`, `mail_content:html` or `credential:secret`: the list is the proof, not the promise.
* A response body that is not JSON or markdown is not stored at all; `bytes` and `sha256` still describe it.

## SDK and version

The `User-Agent` of our own SDKs carries the name and the version (`aimulti-node/1.2.0`, `aimulti-go/v1`). The log normalises it into `sdk` and `sdk_version`, compares it with the newest version this deployment knows and sets `sdk_update_available` with a sentence a person can act on. Nothing is fetched from outside: `MAI_SDK_LATEST` (`node=1.3.0,go=1.1.0`) is what the comparison uses, so shipping a new client does not need a new API release. The same notice arrives as the `X-Mai-Sdk-Update` header on the call itself, so an integrator sees it without opening the console.

## Copy for an assistant

`GET /v0/logs/{log}/export` answers `text/markdown`: the shape of the call, the redacted bodies and an explicit list of what the log does not contain. It is a copy a person pastes where they work, not an integration: nothing is sent to a third party and no external service is called.

## Retention

Seven days, enforced by the retention job: the row is deleted by age and not by request. The table is in the same purge job and the same weekly restore drill as every other table, so the published window is a fact and not a target.

> Note: The log is switched per organization by the platform operator, and `MAI_REQUEST_LOGS_ENABLED` is the deployment's kill switch: both have to be on. While either is off nothing is recorded for the organization and the routes answer `501 request_logs_disabled`. `GET /v1/organization/features` says which switch is off.

### GET /v0/logs

Redacted API request log of the organization, newest first (requires audit_read)

Parameters:

| Name | Type | Notes |
| --- | --- | --- |
| `limit` | integer | in query, min 1, max 200, default 50 |
| `key_id` | string (uuid) | in query |
| `method` | string | in query |
| `status` | integer | in query, min 100, max 599 |
| `sdk` | go \| node \| python \| php \| ruby \| java | in query |
| `after` | string (date-time) | in query |
| `before` | string (date-time) | in query |

Responses:

* `200` OK: object
* `400` Error envelope: Error
* `403` Error envelope: Error
* `501` Error envelope: Error

### GET /v0/logs/{log}

One API request with its redacted request and response body (requires audit_read)

Parameters:

| Name | Type | Notes |
| --- | --- | --- |
| `log` | string (uuid) | in path, required |

Responses:

* `200` OK: RequestLog
* `403` Error envelope: Error
* `404` Error envelope: Error
* `501` Error envelope: Error

### GET /v0/logs/{log}/export

The same request as a markdown block a person can paste into an assistant (requires audit_read)

Parameters:

| Name | Type | Notes |
| --- | --- | --- |
| `log` | string (uuid) | in path, required |

Responses:

* `200` OK: string
* `403` Error envelope: Error
* `404` Error envelope: Error
* `501` Error envelope: Error

### RequestLogEntry object

| Name | Type | Notes |
| --- | --- | --- |
| `id` | string (uuid) |  |
| `request_id` | string | The X-Request-Id of the call |
| `key_id` | string (uuid) | The key that made the call; absent for a person signed in |
| `actor` | string | user:<id> or api_key:<id> |
| `actor_type` | user \| agent |  |
| `method` | string |  |
| `path` | string | Path and filters, with the value of a credential-shaped query parameter masked |
| `status` | integer |  |
| `duration_ms` | integer |  |
| `sdk` | string | SDK name normalised from the User-Agent; absent when the caller is not one of our SDKs |
| `sdk_version` | string |  |
| `sdk_update_available` | boolean | A newer version of that SDK exists on this deployment |
| `sdk_latest` | string |  |
| `sdk_notice` | string | The sentence to show a person: which SDK, which version, which one to move to |
| `created_at` | string (date-time) |  |

### RequestLogBody object

| Name | Type | Notes |
| --- | --- | --- |
| `headers` | object | Request headers only, from an allowlist |
| `body` | object | The JSON as it was, with credentials and mail content replaced by markers |
| `bytes` | integer | Size of the original body |
| `sha256` | string | Digest of the original body, empty when there was none |
| `truncated` | boolean | The stored body is a summary: the original was larger than the log keeps |
