> Call POST /v1/check before a tool that writes. DENY means do not invoke the tool. For clean Markdown of any page, append .md to the page URL. For the complete index, fetch https://docs.withwrit.com/llms.txt.

# Audit log

> Live streaming, SIEM ingestion, hash-chained tamper evidence, and verification.

Every decision Writ makes — ALLOW, DENY, STEP_UP, REVOKE — lands in your
tenant's audit log as a receipt. The log is the product's answer to the
auditor: instead of dumping CloudTrail and hoping, the compliance owner
opens the receipt and sees who, what, which purpose, which policy version,
when.

## Watch it live

Three ways, same stream:

```bash
./writ stream --key writ_KEY            # terminal tail, reconnects automatically
```

```js
// browser / SIEM: server-sent events, resumes with Last-Event-ID
const es = new EventSource("https://api.withwrit.com/v1/receipts/stream?apiKey=writ_KEY");
es.addEventListener("receipt", e => ingest(JSON.parse(e.data)));
```

Or poll `GET /v1/receipts?since=<receiptId>` — it returns only newer
receipts plus a `cursor`.

### Filter by decision

`GET /v1/receipts/stream?decision=DENY` streams only one decision —
`ALLOW`, `DENY`, or `STEP_UP` (case-insensitive; anything else is a 400).
The filter applies to the initial replay and to every frame emitted while
the connection is held.

The DENY-only stream shows every write Writ blocked before
it happened, in real time. The dashboard's **Denied** panel is built on
it — verb, target, and reason for each stopped write, with a running
count.

## Streaming to your SIEM

Security teams don't want another dashboard — they want the event stream.
`GET /v1/receipts/stream` is that feed: every gated write your tenant's
agents attempt, as server-sent events, in near real time. This is the
answer to "can we pipe this into our SIEM?"

**Connecting.** Authenticate with `Authorization: Bearer <key>`, or pass
`?apiKey=` — browsers' `EventSource` can't set headers, which is why the
query param exists. The server holds the connection up to `timeout`
seconds (default 10, max 12), emitting `event: receipt` frames plus
`: ping` heartbeats, then closes it. Reconnect and resume with `since`
(or `Last-Event-ID`, which `EventSource` sends automatically). Omit
`since` on first connect to replay the most recent `replay` receipts
(default 20) and then go live.

```python
import json, sseclient, requests  # pip install sseclient-py requests

url = "https://api.withwrit.com/v1/receipts/stream"
headers = {"Authorization": "Bearer writ_KEY", "Accept": "text/event-stream"}
last_id = None
while True:  # reconnect loop — the server closes the hold every ~10s by design
    params = {"since": last_id} if last_id else {"replay": 50}
    resp = requests.get(url, headers=headers, params=params, stream=True, timeout=30)
    for event in sseclient.SSEClient(resp).events():
        if event.event == "receipt":
            last_id = event.id
            ingest(json.loads(event.data))  # your Splunk/Datadog/warehouse sink
```

**What each event carries.** Every frame's `data` is a complete receipt:
`receiptId`, `tenantId`, `decision` (ALLOW, DENY, STEP_UP, REVOKE),
`sponsorId`, `agentId`, `verb`, `target`, `purpose`, `reason`,
`policyVersion`, `createdAt`, plus `prevHash`/`chainHash` for the
tamper-evident chain. No joins needed downstream — enrich or index it as
is. Use `receiptId` as the dedup key; the resume cursor guarantees at-least-once delivery across reconnects, never gaps.

**Filtering.** Append `?decision=DENY` to stream only stopped writes —
the feed your SOC actually wants to watch. `ALLOW` and `STEP_UP` work
too; anything else is a 400. The filter applies to the replay and the
live hold alike.

**Tenant isolation.** One tenant's stream never carries another tenant's
receipts. A receipt ID from another tenant is indistinguishable from a
missing one everywhere in the API.

**Polling fallback.** Where SSE is unavailable, poll
`GET /v1/receipts?since=<cursor>` — it returns only newer receipts plus a
`cursor` for the next poll.

## Tamper evidence

Every receipt is hash-chained: each one stores `prevHash` (the previous
receipt's `chainHash`, or `GENESIS` for the first) and
`chainHash = HMAC-SHA256(canonical receipt fields + prevHash)`, keyed by a
server-side secret held only in the deploy environment. The tenant's chain
head advances with a conditional write, so concurrent checks serialize into one linear chain
instead of forking. The canonical field set is version-stable: attribution
fields added after launch (`policyId`, `principalId`, …) are omitted from the
hash when unset, so older receipts keep verifying.

Anyone with raw table access who alters a stored receipt breaks the chain —
and the break is detectable:

```bash
./writ verify-chain --key writ_KEY        # verify the chain, newest first
```

```bash
curl -s -H "Authorization: Bearer <redacted>" \
  "$WRIT_BASE/v1/receipts/verify?limit=200"
```

`GET /v1/receipts/verify` returns
`{"ok": true, "checked": N, "tip": "<hash>", "tipReceipt": "<id>",
"chainedFrom": "<id>", "legacyCount": M, "complete": true}` when the chain
is intact, or `{"ok": false, "brokenAt": "<receiptId>", "reason": "..."}`
at the first receipt that fails.

Receipts written before chaining was enabled have no chain fields;
verification stops at them and reports them as `legacyCount`.

## How the chain stays unforgeable

The chain HMAC is keyed by a server-side secret that never leaves the
gate's deploy environment and is never exposed through the API. Whoever
holds the receipt table but not that secret cannot forge a valid link:
any altered receipt fails verification at the first changed hash.

This is the control that supports **NIST SP 800-53 AU-9 (Protection of Audit
Information)**: audit records are protected against unauthorized modification
and deletion, and tampering is detectable on demand. It also feeds AU-3
(content of audit records) — every receipt records who, what, when, and why
— and AU-12 (audit generation). Writ is not itself certified; your assessor
determines compliance for your deployment, but the chain gives them the
tamper-evidence mechanism AU-9 asks for.

## Agent Action Report

The audit log is the raw material. The Agent Action Report is the executive
artifact — one page a security lead can forward upward.

```bash
writ report --key writ_KEY --format text
```

Example output (illustrative — your numbers come from your own receipts):

```
Agent Action Report
Agent: refund-agent
Period: September 2026

1,284 actions attempted
1,241 allowed
43 denied

$183,421 refunds attempted
$12,400 denied by policy

3 permission revocations
43 unauthorized writes blocked

By verb:
  payments.refund: 1,102 attempted, 1,089 allowed, 13 denied
  iam.grant_admin: 4 attempted, 0 allowed, 4 denied
  ...
```

What it aggregates:

* **Actions** — total attempted, allowed, denied, escalated (`--agent` filters
  to one agent; `--since YYYY-MM-DD` bounds the window; `--period` sets the
  label).
* **Money** — for money verbs (`payments.*`, `billing.*`, `refund*`,
  `payout*`, `charge*`, `transfer*`), the report sums dollar amounts from
  receipt `amount` fields or from the target string (`amount=5000`,
  `$50.00`, `5000 cents`). "Attempted" vs "denied by policy" is the number
  your finance team cares about.
* **Revocations** — permission revocations in the window.
* **Unauthorized writes blocked** — every DENY is a write Writ stopped before
  it happened.
* **By verb** — per-verb attempted/allowed/denied breakdown.

```bash
writ report --key writ_KEY --format json    # machine-readable
writ report --file receipts.json            # offline: JSON array or JSON-lines
writ report --key writ_KEY --agent refund-agent --since 2026-09-01
```

## Billing note

The audit log is also the meter: every receipt is a billable event under
commit + volume pricing, and because you hold the same tamper-evident log,
you can verify usage yourself. See [Pricing](/welcome#pricing).