> ## Documentation Index
> Fetch the complete documentation index at: https://docs.qwedai.tech/llms.txt
> Use this file to discover all available pages before exploring further.

# Authentication

> Authenticate with the QWED API using API keys. Learn header and SDK authentication methods, key formats, environment variables, and security best practices.

How to authenticate with the QWED API.

## API keys

All requests require an API key, except `GET /health` and `GET /health/identity`, which are unauthenticated by design — `qwed init` calls the identity endpoint before it has a key to send.

### Header authentication

```bash theme={null}
curl -H "X-API-Key: qwed_your_key" http://localhost:8000/health
```

A key authenticates only while it is live: active, not revoked, and not past its expiry time. Since v7.3.0, QWED rejects expired and revoked keys on every authenticated path, including `/verify/*`, admin, and metrics routes, with `401 Unauthorized` or `403 Forbidden`. Keys issued by rotation expire after 90 days. Rotate a key before it expires.

### SDK authentication

```python theme={null}
from qwed_sdk import QWEDClient

client = QWEDClient(api_key="qwed_your_key")
```

## Environment variables

Set your API key as an environment variable:

```bash theme={null}
export QWED_API_KEY=qwed_your_key
```

Then the SDK will auto-detect it:

```python theme={null}
client = QWEDClient()  # Uses QWED_API_KEY env var
```

## API key format

QWED API keys use a fixed prefix followed by a fixed-length body. The prefix tells you the key type. The body shape tells you the key version.

| Prefix | Type |
| - | - |
| `qwed_live_` | Production API key (v2 or legacy v1 body) |
| `qwed_test_` | Non-production test/sandbox key |
| `qwed_agent_` | Agent token |

### Production key versions

| Version | Status | Pattern | Body |
| - | - | - | - |
| v2 | Current, checksummed | `qwed_live_[A-Za-z0-9]{36}` | 30 random Base62 characters (\~178.6 bits of entropy) followed by a 6-character checksum |
| v1 | Legacy, still accepted | `qwed_live_[A-Za-z0-9_-]{43}` | 43 base64url characters (32 random bytes, padding stripped, 256 bits of entropy). No checksum. |

QWED issues new keys in the v2 format. Existing v1 keys keep working and are never force-rotated. `qwed_test_` keys use the same body versions; a test key can be a structurally valid v2 (checksummed) key, so treat the prefix—not the shape—as the production/non-production signal.

The v2 checksum is CRC32 (IEEE, as in `zlib`) over the ASCII encoding of the first 30 body characters. QWED Base62-encodes the result with the alphabet `0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz` and zero-pads it to a width of 6. You can validate a v2 key offline: split the body into the first 30 and last 6 characters, recompute the checksum, and accept only on an exact match. This reduces random false-positive matches to about 2^-32 without a database call.

> [!NOTE]
>
> The checksum authenticates the key's shape, not its authorization. A key with a valid checksum still has to pass the server-side HMAC lookup before QWED accepts it.

```python theme={null}
import zlib

ALPHABET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"

def base62(n: int, width: int = 6) -> str:
    out = ""
    while n:
        n, r = divmod(n, 62)
        out = ALPHABET[r] + out
    return out.rjust(width, "0")

def is_valid_v2(key: str) -> bool:
    prefix = "qwed_live_"
    if not key.startswith(prefix):
        return False
    body = key[len(prefix):]
    if len(body) != 36 or any(c not in ALPHABET for c in body):
        return False
    random_part, checksum = body[:30], body[30:]
    return base62(zlib.crc32(random_part.encode("ascii"))) == checksum
```

### Scanning for leaked keys

If you build a secret scanner, leak detector, or pre-commit hook, use these rules:

* **Match only `qwed_live_`.** Exclude `qwed_test_` from high-confidence production-secret matching. Registered scanner patterns match only keys with the `qwed_live_` prefix, so scanners do not report test keys to QWED. The revocation sink itself applies no prefix filter.
* **Anchor with lookarounds, not `\b`.** A v1 body can end in `-`, which no word boundary borders. Use charset-complement lookarounds so a match ends exactly where the body alphabet stops.
* **Post-process v2 matches.** Recompute the checksum and discard matches that fail. For v1, enforce the exact shape only.
* **With the RE2 patterns below, use the capture group.** Send group 1 as the token and resume scanning after it; the whole match includes surrounding delimiter characters.

```text theme={null}
# v2 (current)
(?<![A-Za-z0-9])qwed_live_[A-Za-z0-9]{36}(?![A-Za-z0-9])

# v1 (legacy)
(?<![A-Za-z0-9_-])qwed_live_[A-Za-z0-9_-]{43}(?![A-Za-z0-9_-])
```

On regex engines without lookaround support (for example, RE2), use an explicit delimiter class instead. The key is wrapped in a capture group; the surrounding `(?:^|[^...])` and `(?:[^...]|$)` delimiters match characters that are not part of the key, so always read the match's **group 1** as the token, never the whole match. Submitting the whole match sends one or two extra characters around the key, which breaks checksum validation and the revocation lookup. Group 1 also lets a global scan continue from the end of the key rather than the end of the whole match, so back-to-back keys separated by a single delimiter are not skipped:

```text theme={null}
# v2 (current)
(?:^|[^A-Za-z0-9])(qwed_live_[A-Za-z0-9]{36})(?:[^A-Za-z0-9]|$)

# v1 (legacy)
(?:^|[^A-Za-z0-9_-])(qwed_live_[A-Za-z0-9_-]{43})(?:[^A-Za-z0-9_-]|$)
```

The registered pattern names are `QWED_LIVE_API_KEY` (v2) and `QWED_LIVE_API_KEY_V1` (v1). QWED receives signed leak reports for these patterns at [`POST /webhooks/secret-scanning`](https://docs.qwedai.tech/api/endpoints#post-%2Fwebhooks%2Fsecret-scanning).

### Masking keys

When you display a key in logs, dashboards, documentation, or examples, show the prefix, asterisks, and only the last 4 characters. Never show a full key body.

```text theme={null}
qwed_live_********************************wDHo
```

### Machine-readable spec and test vectors

Scanner and SDK authors can consume the canonical format directly:

* [`spec/api-key-format.json`](https://github.com/QWED-AI/qwed-verification/blob/main/spec/api-key-format.json): prefixes, full and anchored patterns, checksum parameters, and excluded prefixes.
* [`spec/api-key-test-vectors.json`](https://github.com/QWED-AI/qwed-verification/blob/main/spec/api-key-test-vectors.json): synthetic, non-functional valid and invalid keys with expected classifications (`v2`, `v1`, `invalid`, or `excluded` for test-prefix keys that are shape-valid but must never alert). None of these values authenticate.
* [`docs/API_KEY_FORMAT.md`](https://github.com/QWED-AI/qwed-verification/blob/main/docs/API_KEY_FORMAT.md): the human-readable specification.

Run your validator against every vector before shipping.

## API key storage and migration (v7.2)

As of v7.2, QWED stores API keys as HMAC-SHA256 lookup digests keyed by a dedicated server-side secret (`QWED_API_KEY_LOOKUP_SECRET`). Earlier versions used PBKDF2, which added \~67ms of CPU cost to every request on the unauthenticated lookup path. Because API keys are high-entropy random tokens, the key-derivation cost bought no additional brute-force resistance, so it was removed.

> [!WARNING]
>
> **Breaking change.** There is no legacy fallback: API keys issued before v7.2 no longer authenticate and return `401 Unauthorized`. Each key must be re-issued once. The old raw key is never required:
>
> * **Portal or API:** sign in with your email and password to get a JWT, then call `POST /auth/api-keys` to mint a new key. This path needs no API key.
> * **Rotation by key ID:** call [`POST /admin/keys/rotate`](https://docs.qwedai.tech/api/endpoints#post-%2Fadmin%2Fkeys%2Frotate) with the key's ID, authenticated with an already-working key in the same organization or, from v9.0.0, a [signed-in session](https://docs.qwedai.tech/api/endpoints.md#session-sign-in-for-admin-endpoints). Members can rotate only their own keys. Owners and admins can also rotate other users' keys, unless the key owner holds a higher role. Revoked or inactive keys can't be rotated. Rotation is atomic: it mints at most one replacement key, even when several rotations of the same key run concurrently. Rotating a key that is already inactive mints nothing and returns `404 Not Found`.
>
> ```bash theme={null}
> # Before (pre-v7.2 key): now rejected
> curl -X POST http://localhost:8000/verify/math \
>   -H "X-API-Key: qwed_live_old_key" \
>   -H "Content-Type: application/json" \
>   -d '{"expression": "1 + 1 = 2"}'
> # -> 401 Unauthorized
>
> # After: re-issue via JWT login (no API key required)
> curl -X POST http://localhost:8000/auth/signin \
>   -H "Content-Type: application/json" \
>   -d '{"email": "user@example.com", "password": "..."}'
>
> curl -X POST http://localhost:8000/auth/api-keys \
>   -H "Authorization: Bearer eyJhbG..." \
>   -H "Content-Type: application/json" \
>   -d '{"name": "Production Key"}'
> ```

### Self-hosted configuration

Self-hosted deployments must set `QWED_API_KEY_LOOKUP_SECRET` before starting the server:

* The server fails closed at startup if the variable is missing.
* The value must differ from `QWED_JWT_SECRET_KEY`. Equal values are rejected at startup, because reusing the JWT secret would break every API-key lookup on the next JWT-secret rotation.
* Set the secret before issuing any v7.2 keys. Digests are derived from the secret active at issue time, so changing it later requires a one-time re-issue of all keys.

```bash theme={null}
# Generate a dedicated secret
python -c "import secrets; print(secrets.token_urlsafe(48))"
```

## Security best practices

1. **Never commit API keys** to version control
2. **Use environment variables** in production
3. **Rotate keys regularly** using the dashboard
4. **Set IP allowlists** for production keys
5. **Use test keys** for development

## Agent authentication

Agent tokens are separate from API keys and are used for agent-specific endpoints.

```python theme={null}
# Register agent (requires API key auth)
response = client.register_agent(name="MyBot", ...)
agent_token = response["agent_token"]  # qwed_agent_...

# Use agent token via X-Agent-Token header
client.verify_action(
    agent_id=response["agent_id"],
    action={...}
)
```

Agent tokens are passed via the `X-Agent-Token` header:

```bash theme={null}
curl -X POST http://localhost:8000/agents/42/verify \
  -H "X-Agent-Token: qwed_agent_..." \
  -H "Content-Type: application/json" \
  -d '{"query": "What is 2+2?"}'
```

### Agent token storage

QWED stores each agent token as an HMAC-SHA256 digest keyed with `QWED_API_KEY_LOOKUP_SECRET`, not as plain text. The token is shown only once, in the response to registration or re-issue. Store it when you receive it.

Tokens issued before this change keep working. At startup, after its fail-closed checks, the server hashes any agent token still stored in plain text, in place. You don't need to re-register agents.

To replace a token, for example one whose plain text might remain in a database backup, call [`POST /agents/{agent_id}/token/reissue`](https://docs.qwedai.tech/api/endpoints#post-%2Fagents%2F%2Ftoken%2Freissue). QWED returns a new token and the old one stops working.

> [!WARNING]
>
> Agent token digests depend on `QWED_API_KEY_LOOKUP_SECRET`. Changing the secret invalidates every agent token as well as every API key. Re-issue agent tokens after changing it.

## Scopes

API keys can have restricted scopes:

| Scope | Access |
| - | - |
| `verify:read` | Verification endpoints |
| `agent:write` | Agent management |
| `attestation:read` | Attestation queries |
| `admin:all` | Full access |

## Auth endpoints

The following endpoints manage user accounts and API keys via JWT-based authentication.

Anonymous `/auth/*` routes are rate limited per client IP (default 10 requests per minute). Exceeding the limit returns `429 Too Many Requests` with a `Retry-After` header. See [Per-IP limits on authentication endpoints](https://docs.qwedai.tech/api/rate-limits.md#per-ip-limits-on-authentication-endpoints).

### POST /auth/signup

Create a new user and organization. Returns a JWT token for immediate use.

**Request:**

```json theme={null}
{
  "email": "user@example.com",
  "password": "securepassword",
  "organization_name": "Acme Corp"
}
```

The password can be at most 72 bytes in UTF-8. bcrypt reads no more than 72 bytes, so QWED refuses a longer password with `400 Bad Request` and `"Password must be at most 72 bytes"`. Characters outside ASCII take more than one byte each, so a password with fewer than 72 characters can still be too long. QWED checks the length before it looks up the email, so the response is the same whether or not the email is registered.

**Response:**

```json theme={null}
{
  "access_token": "eyJhbGciOiJIUzI1NiIs...",
  "token_type": "bearer",
  "user": {
    "id": "1",
    "email": "user@example.com",
    "org_id": "1",
    "org_name": "Acme Corp",
    "role": "owner"
  }
}
```

`org_name` is the organization's display name, or `null` if QWED can't read it.

### POST /auth/signin

Sign in an existing user.

**Request:**

```json theme={null}
{
  "email": "user@example.com",
  "password": "securepassword"
}
```

**Response:** Same format as `/auth/signup`.

A password over 72 bytes in UTF-8 can't match any account. Sign-in returns `401 Unauthorized` with `"Invalid email or password"` for it, whether or not the email is registered.

Sign-in returns `403 Forbidden` for a deactivated user (`"Account is deactivated"`) and for a user whose organization is inactive or missing (`"Organization is not active"`).

### GET /auth/me

Get the current authenticated user's information. Requires a `Bearer` token in the `Authorization` header.

```bash theme={null}
curl -H "Authorization: Bearer eyJhbG..." http://localhost:8000/auth/me
```

Returns the same `user` object as sign-up and sign-in, including `org_name`. QWED applies the user and organization checks in [Roles on API-key and audit routes](#roles-on-api-key-and-audit-routes) on every call. An inactive user or organization, or an unknown role, returns `403 Forbidden` even while the token is still valid.

### GET /auth/users

List the users of your organization, oldest first. Use this to show who is in the organization, or to name the owner of a key or the user behind a security event.

Owners and admins can call this endpoint with a bearer token or with an API key in the `X-API-Key` header. When you send both, QWED uses the API key. Members and viewers get `403 Forbidden`.

```bash theme={null}
curl --fail-with-body -H "Authorization: Bearer eyJhbG..." http://localhost:8000/auth/users
```

**Response:**

```json theme={null}
[
  {
    "id": "1",
    "email": "owner@example.com",
    "role": "owner",
    "is_active": true,
    "created_at": "2026-03-20T12:00:00Z"
  },
  {
    "id": "7",
    "email": "former@example.com",
    "role": "member",
    "is_active": false,
    "created_at": "2026-05-02T08:30:00Z"
  }
]
```

QWED returns only users of your own organization. Inactive users are included with `is_active` set to `false`. The response contains no password or permission data.

| Status | Meaning |
| - | - |
| 401 | No API key or bearer token, or an invalid or expired bearer token |
| 403 | An invalid, revoked, or expired API key; not an owner or admin; or an inactive user, organization, or key |

### Roles on API-key and audit routes

The `/auth/api-keys`, `/auth/users`, and `/audit` routes authorize by the signed-in user's role. Roles rank from lowest to highest: `viewer`, `member`, `admin`, `owner`.

| Route | `viewer` | `member` | `admin` | `owner` |
| - | - | - | - | - |
| `POST /auth/api-keys` | No | Yes | Yes | Yes |
| `GET /auth/api-keys` | Own keys | Own keys | All keys in the organization | All keys in the organization |
| `DELETE /auth/api-keys/{key_id}` | Own keys | Own keys | Own keys, plus keys of users who don't rank higher | Any key in the organization |
| `GET /auth/users` | No | No | Yes | Yes |
| `GET /audit/logs` | Yes | Yes | Yes | Yes |
| `GET /audit/logs/{log_id}` | Yes | Yes | Yes | Yes |
| `GET /audit/export` | No | No | Yes | Yes |
| `POST /audit/seed` | No | No | Yes | Yes |

Before QWED checks the role, it checks the user and organization. QWED returns `403 Forbidden` when:

* The user is inactive (`"User is not active"`).
* The user's organization is inactive or missing (`"Organization is not active"`).
* The user's role isn't allowed on the route, including any role not listed above (`"Insufficient permissions for this operation"`).

### POST /auth/api-keys

Generate a new API key for the current user's organization. Requires JWT authentication and the `member`, `admin`, or `owner` role. A `viewer` gets `403 Forbidden`.

**Request:**

```json theme={null}
{
  "name": "Production Key"
}
```

**Response:**

```json theme={null}
{
  "id": "1",
  "name": "Production Key",
  "key": "qwed_live_abc123...",
  "created_at": "2026-03-20T12:00:00Z"
}
```

> [!WARNING]
>
> The `key` field is only returned once at creation time. Store it securely.

### GET /auth/api-keys

List active API keys. Admins and owners see every active key in their organization. Members and viewers see only their own keys.

Each key includes `id`, `name`, `key_preview`, `created_at`, `last_used_at`, and `is_revoked`. QWED sets `last_used_at` after a key passes every authentication and authorization check, at most once a minute per key. A key that has never been used, or was refused by a check, keeps its previous value (`null` if never used). If QWED can't write `last_used_at`, the request still succeeds.

Each key also includes:

* `expires_at`: when the key expires, or `null` if it doesn't expire.
* `rotation_required`: `true` when the key is flagged for rotation.
* `owner`: the user the key belongs to, as `{"id": ..., "email": ...}`. It is `null` for a key with no user, or whose user isn't in the key's organization, so QWED never shows another organization's user.

`created_at`, `last_used_at`, and `expires_at` always carry their UTC offset.

```json theme={null}
[
  {
    "id": "12",
    "name": "Production Key",
    "key_preview": "qwed_live_ab...",
    "created_at": "2026-03-20T12:00:00Z",
    "last_used_at": "2026-10-28T09:14:03Z",
    "is_revoked": false,
    "expires_at": null,
    "rotation_required": false,
    "owner": {"id": "1", "email": "owner@example.com"}
  }
]
```

### DELETE /auth/api-keys/{key_id}

Revoke an API key. Performs a soft delete.

* Any role can revoke its own key.
* An `admin` can revoke another user's key unless that user holds a higher role. Revoking an owner's key returns `403 Forbidden`.
* An `owner` can revoke any key in the organization.
* A `member` or `viewer` gets `404 Not Found` for a key they don't own, so the response doesn't confirm that the key exists.
* A key ID that isn't a positive integer, or that belongs to another organization, returns `404 Not Found`.

```bash theme={null}
curl --fail-with-body -X DELETE http://localhost:8000/auth/api-keys/12 \
  -H "Authorization: Bearer eyJhbG..."
```

## Audit endpoints

These endpoints require JWT authentication (Bearer token) and return data scoped to the authenticated user's organization. Every role can read the log. Exporting it and seeding samples need `admin` or `owner`. See [Roles on API-key and audit routes](#roles-on-api-key-and-audit-routes).

### GET /audit/logs

Get audit logs for the current organization, newest first.

| Parameter | Type | Default | Description |
| - | - | - | - |
| `limit` | integer | `50` | Maximum records (max 200) |
| `status` | string | — | Filter by `verified` or `blocked` |
| `before` | integer | — | Return only rows older than this log ID. Pass the `next_before` value from the previous page |

Each entry includes `verdict`, the verdict stored in the row's result, `admission`, the admission decision its route made, and `entry_hash`, the row's hash in the signed audit chain. Each entry also records who wrote it (`api_key_id`) and with which server release (`verifier_version`).

```json theme={null}
{
  "total": 1,
  "next_before": null,
  "logs": [
    {
      "id": "42",
      "request_input": "x**2 + 2*x + 1 = (x+1)**2",
      "verification_result": "{\"status\": \"VERIFIED\", ...}",
      "engine_used": "MATH",
      "verdict": "VERIFIED",
      "status": "verified",
      "admission": "ADMIT",
      "latency_ms": 184,
      "created_at": "2026-10-21T12:00:00+00:00",
      "entry_hash": "3b1f...a9c2",
      "api_key_id": "7",
      "verifier_version": "8.0.0"
    }
  ]
}
```

| Field | Description |
| - | - |
| `total` | Number of rows on this page, not in the whole log |
| `engine_used` | The row's domain, such as `MATH`, `NATURAL_LANGUAGE`, `LOGIC`, `STATS`, `FACT`, `CODE`, `SQL`, `IMAGE`, `RAG`, `PROCESS`, `CONSENSUS`, or `VERIFICATION_CONTEXT`. `/verify/natural_language`, `/agents/{agent_id}/verify`, and natural-language `/verify/batch` items write `NATURAL_LANGUAGE`. Their rows written before this fix read `MATH`. The domain is part of the signed, chained entry, so QWED doesn't rewrite those rows |
| `next_before` | The last row's ID when the page is full (`limit` rows). `null` when the page is not full |
| `verdict` | `VERIFIED`, `UNVERIFIABLE`, or `BLOCKED`, read from the stored result: `status` for a `/verify/*` result, `verdict` for a Verification Context document, or `details.result.status` for a `/verify/batch` item row. `null` for anything else, such as a truncated result, a logic engine's `SAT`, or an audit seed sample. Never derived from `is_verified` |
| `status` | Unchanged for existing clients: `verified` or `blocked`, from the row's `is_verified` flag. Each route sets that flag by its own rule, so an `UNVERIFIABLE` result reads `blocked` |
| `admission` | The admission decision the route made, as stored: `ADMIT` or `BLOCKED`. Rows from `POST /verification-context/from-diagnostic` store `ADMIT` or `DENY`, the Verification Context terms. `null` for routes that decide no admission (logic, stats, fact, image, RAG, process, consensus, and batch items other than code) and for rows written before QWED stored it |
| `entry_hash` | The row's SHA-256 hash in the audit chain, as stored. `null` for legacy rows written before the API used the chain |
| `api_key_id` | ID (as a string) of the API key that authenticated the request. `null` for rows written from a signed-in user session, by `POST /audit/seed`, or by `POST /agents/{agent_id}/verify` (which authenticates with an agent token), and for rows written before QWED recorded it |
| `verifier_version` | The QWED server version that wrote the row. `null` for rows written before QWED recorded it |
| `latency_ms` | Whole milliseconds from the start of the HTTP request to when QWED began writing the audit row, before it waits for the append lock. It includes the verifier, any LLM call, and everything else before that point. QWED measures it itself and never reads it from the verifier's or caller's result. `null` for rows written outside an HTTP request and for rows written before QWED recorded it |

The entry hash covers `admission`, `api_key_id`, `verifier_version`, and `latency_ms`. Changing or clearing any of them on a stored row fails [integrity verification](https://docs.qwedai.tech/api/endpoints.md#get-%2Fadmin%2Fcompliance%2Fverify%2F).

> [!WARNING]
>
> `verdict` is not an admission decision. `VERIFIED` means the check ran to a proof, not that the claim holds. A disproven identity or code proven unsafe is `VERIFIED` with `status` `blocked`. Don't gate on `verdict` or `status`. Read `admission` for the decision the route made.

#### Paging through the log

QWED orders rows by timestamp, then by ID, newest first, so the order is stable when two rows share a timestamp. To read the next page, pass `next_before` back as `before`. Stop when `next_before` is `null`. A full last page is followed by an empty page with `next_before: null`.

```bash theme={null}
# First page
curl --fail-with-body -H "Authorization: Bearer $TOKEN" \
  "http://localhost:8000/audit/logs?limit=200"

# Next page, using next_before from the previous response
curl --fail-with-body -H "Authorization: Bearer $TOKEN" \
  "http://localhost:8000/audit/logs?limit=200&before=1042"
```

A `before` ID that is not a log in your organization, or is outside the SQL `BIGINT` range (1 to 2<sup>63</sup>−1), returns `404 Not Found`.

### GET /audit/logs/{log_id}

Get detailed information for a single audit log entry. Returns `404` for a log ID outside your organization or outside the SQL `BIGINT` range.

The response has the same `verdict`, `status`, `admission`, `api_key_id`, and `verifier_version` fields as the list, plus the row's full chain entry as stored:

| Field | Description |
| - | - |
| `entry_hash` | The row's own hash |
| `previous_hash` | The hash of the previous row in your organization's chain. `null` for the first row of a chain segment |
| `hmac_signature` | The row's HMAC-SHA256 signature under `QWED_AUDIT_SECRET_KEY` |

These fields identify the chain entry. To check that it is intact, call [`GET /admin/compliance/verify/{log_id}`](https://docs.qwedai.tech/api/endpoints#get-%2Fadmin%2Fcompliance%2Fverify%2F).

### GET /audit/export

Export your organization's audit log as a CSV file (`text/csv`). Requires the `admin` or `owner` role.

| Parameter | Type | Default | Description |
| - | - | - | - |
| `start` | datetime | — | Export only rows at or after this time |
| `end` | datetime | — | Export only rows at or before this time |

Pass `start` and `end` as ISO 8601 times. A time without a zone is read as UTC, and other offsets are converted. Without either, QWED doesn't bound that side of the period, so with neither you get the newest rows. `start` after `end` returns `400 Bad Request` with `"start must not be after end"`.

```bash theme={null}
curl --fail-with-body -D headers.txt -o audit.csv \
  -H "Authorization: Bearer $TOKEN" \
  "http://localhost:8000/audit/export?start=2026-10-01T00:00:00Z"
```

The file has the same columns as [`GET /admin/compliance/export/csv`](https://docs.qwedai.tech/api/endpoints#csv-columns): `ID`, `Timestamp`, `User ID`, `API Key ID`, `Verifier Version`, `Domain`, `Query`, `Verdict`, `Admission`, `Is Verified (legacy)`, `Result`, `Latency (ms)`, `Entry Hash`, `HMAC Signature`, and `Chain Status`. `Verdict`, `Admission`, and `Latency (ms)` are the stored values, as `verdict`, `admission`, and `latency_ms` on [`GET /audit/logs`](#get-audit-logs). `Domain` is the same value as `engine_used`. A value QWED didn't record is an empty cell, and no cell is truncated. QWED prefixes an apostrophe to any cell that starts with `=`, `+`, `-`, `@`, a tab, a carriage return, or a line feed, so spreadsheet software doesn't run it as a formula.

Rows are newest first, ordered by timestamp and then by ID. The export holds at most the newest 1,000 rows. When more rows matched, the response sets `X-Export-Truncated: true`, otherwise `false`; CORS exposes it to browser clients on allowed origins.

> [!NOTE]
>
> Before this change, `GET /audit/export` wrote `is_verified` as `verified` or `blocked` under a `Status` column, with no admission or caller. Update any client that reads the old columns by name or position.


## Related topics

- [API overview](https://docs.qwedai.tech/api/overview.md)
- [Testing your integration](https://docs.qwedai.tech/integration/testing.md)
- [QWED A2A deployment for zero-trust agent gateways](https://docs.qwedai.tech/a2a/deployment.md)
- [Rate limits](https://docs.qwedai.tech/api/rate-limits.md)
- [QWED-Agent specification v1.1](https://docs.qwedai.tech/specs/agent.md)


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.