> 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.

# Errors

> Status codes, error shapes, and the gotchas — including the 200s that mean no.

Errors are JSON with an `error` string and, where useful, structured
detail. Two Writ-specific gotchas first, because they surprise people:

* **DENY is HTTP 200.** `POST /v1/check` returns 200 for ALLOW, DENY,
  and STEP_UP alike — the decision is in the body. Only auth failures,
  quota exhaustion, and malformed requests change the status code.
* **An invalid auth token is HTTP 200.** `POST /v1/tokens/verify`
  returns `{"valid": false, "reason": "..."}` with a 200. Only a bad
  *API key* on that endpoint is a 401.

## Status codes

| Code | Meaning                                                                | Typical body                                                                         |
| ---- | ---------------------------------------------------------------------- | ------------------------------------------------------------------------------------ |
| 200  | OK — including DENY/STEP_UP decisions and `valid: false` token checks  | the resource or decision                                                             |
| 201  | Created — key, invite, grant, revocation, sandbox grant                | the created object                                                                   |
| 400  | Bad request — malformed JSON body, missing fields, invalid enum values | `{"error": "missing fields", "fields": [...]}`                                       |
| 401  | Unauthorized — missing, malformed, or unknown credential               | `{"error": "API key required..."}` or `{"error": "sponsor token required"}`          |
| 402  | Payment required — free check quota exhausted                          | `{"error": "free tier exhausted", "checksUsed": 10000, "monthlyChecks": 10000, ...}` |
| 404  | Not found — unknown route, or a receipt that isn't yours               | `{"error": "not found"}` / `{"error": "receipt not found"}`                          |
| 429  | Rate limited — key issuance, 10/hour per IP                            | `{"error": "too many keys requested from this address; try again later"}`            |

## Auth errors (401)

Writ has two credentials and they are not interchangeable. Using an API
key where the sponsor token is required (grants, revoke, reinstate,
revoked list) returns `{"error": "sponsor token required"}`; using a
sponsor token on an API-key endpoint returns the API-key error. An
unknown or malformed API key returns `{"error": "unknown API key"}`.

## Quota (402)

Each tenant gets 10,000 receipts per calendar month on the free tier.
Exhausting it returns 402 on `POST /v1/check` — the check is not
evaluated and no receipt is written. The response tells you where you
stand:

```json
{
  "error": "free tier exhausted",
  "checksUsed": 10000,
  "monthlyChecks": 10000,
  "checkout": "/v1/checkout"
}
```

## Infrastructure failure (200, marked)

If the gate itself cannot reach its decision store (DynamoDB down, a
southbound timeout) `POST /v1/check` does **not** 500. It answers 200 with
an explicitly marked envelope instead:

```json
{
  "receiptId": "rcpt_4a9f...",
  "decision": "ALLOW",
  "verb": "refund",
  "reason": "infrastructure failure — decision unavailable (fail-open; policy was NOT evaluated; verify out of band)",
  "infrastructureFailure": true,
  "failMode": "open"
}
```

There is no `authToken`, no policy attribution, and nothing is durably
receipted — the audit hash chain is untouched. `failMode` is `"open"` by
default (`WRIT_CHECK_FAILURE_MODE=failopen`); strict tenants switch the
gate to fail-closed, which returns the same shape with
`decision: "DENY"` and `failMode: "closed"`. Treat both envelopes as
"decision unavailable" and verify out of band: an open-mode ALLOW means
"the gate is unreachable", not "the policy said yes".

Self-host operators can watch for this condition with a CloudWatch metric
filter (or any log aggregator) on the structured line
`{"writ_metric": "check_infra_failure", "failMode": "open|closed"}`. It is
emitted once per infra-failure envelope so unreceipted fail-open traffic
is observable.

Quota resets on the first of the month. The kill switch
(`POST /v1/revoke`) is never gated — revocation always works.

## Not found (404)

`GET /v1/receipts/{receiptId}` returns 404 both when the receipt doesn't
exist **and** when it belongs to another tenant — deliberately, so one
tenant cannot probe another's receipt IDs. Unknown routes return
`{"error": "not found"}`.

## Retries

`POST /v1/check` is safe to retry on network failure: a retried check
simply writes another receipt (each check is a distinct decision event).
`POST /v1/grants` is not idempotent — retrying mints a second grant —
so generate grants once and reuse the `grantId`. The stream
(`GET /v1/receipts/stream`) is designed for reconnects: resume with
`since` or `Last-Event-ID` and the cursor guarantees no gaps.