diff --git a/documentation/TABLEOFCONTENTS.md b/documentation/TABLEOFCONTENTS.md
index b74b97a..0e3deb2 100644
--- a/documentation/TABLEOFCONTENTS.md
+++ b/documentation/TABLEOFCONTENTS.md
@@ -6,6 +6,7 @@
- **Plex OAuth, JWT sessions, RBAC** → [backend/services/auth.md](backend/services/auth.md)
- **Local admin authentication, password change** → [backend/services/auth.md](backend/services/auth.md)
- **Admin-generated login token per user (URL-login)** → [backend/services/auth.md](backend/services/auth.md)
+- **API tokens (allowlist, write capability, /api-docs)** → [backend/services/api-tokens.md](backend/services/api-tokens.md)
- **Route protection, auth guards** → [frontend/routing-auth.md](frontend/routing-auth.md)
- **Login page UI/UX** → [frontend/pages/login.md](frontend/pages/login.md)
- **Credential recovery (lost CONFIG_ENCRYPTION_KEY, locked-out admin)** → [admin-features/credential-recovery.md](admin-features/credential-recovery.md)
diff --git a/documentation/backend/services/api-tokens.md b/documentation/backend/services/api-tokens.md
new file mode 100644
index 0000000..2e8283c
--- /dev/null
+++ b/documentation/backend/services/api-tokens.md
@@ -0,0 +1,84 @@
+# API Tokens
+
+**Status:** ✅ Implemented | Personal long-lived tokens, allowlisted endpoints, write capability per issue #169
+
+## Overview
+Static `rmab_`-prefixed tokens act with the owner's full user-level permissions on a fixed allowlist of endpoints. JWT sessions are NOT restricted by the allowlist.
+
+## Key Details
+- **Prefix:** `rmab_` (12-char stored display prefix: `rmab_` + 7 hex chars)
+- **Storage:** SHA-256 hash in `apiToken.tokenHash`; full token shown ONCE on create
+- **Role binding:** Token `role` matches token owner's role at creation time; admin tokens require admin-created
+- **Per-user cap:** 25 active (non-expired) tokens (`MAX_TOKENS_PER_USER`)
+- **Expiry:** Optional (`never`, `30d`, `90d`, `1y`)
+- **Soft-deleted users:** Tokens reject if `tokenUser.deletedAt` is set
+- **Identity attribution:** `req.user.id` resolves to `apiToken.userId` (target user), NOT `apiToken.createdById`
+- **Header:** `Authorization: Bearer rmab_`
+
+## Allowed Endpoints
+| Method | Path | Title | Write | Admin |
+|---|---|---|---|---|
+| GET | `/api/auth/me` | Current user | | |
+| GET | `/api/audiobooks/search` | Search audiobooks | | |
+| GET | `/api/requests` | List requests | | |
+| POST | `/api/requests` | Create request | ✓ | |
+| GET | `/api/requests/:id` | Get request by ID | | |
+| GET | `/api/admin/metrics` | System metrics | | ✓ |
+| GET | `/api/admin/downloads/active` | Active downloads | | ✓ |
+| GET | `/api/admin/requests/recent` | Recent requests | | ✓ |
+
+Source of truth: `src/lib/constants/api-tokens.ts` (`API_TOKEN_ALLOWED_ENDPOINTS`, `API_TOKEN_ENDPOINT_DOCS`).
+
+## Matcher (`isEndpointAllowed`)
+- Compiled once at module load.
+- `path` entries containing `:name` are converted to anchored regexes where each placeholder matches `[^/]+` (a single segment).
+- Sibling sub-routes (e.g. `/api/requests/:id/select-torrent`) are NOT matched by the `/api/requests/:id` entry — they require their own allowlist entry.
+- Method comparison is case-insensitive.
+
+## POST `/api/requests` (Write)
+- Body: `{ "audiobook": { "asin", "title", "author", "narrator?", "description?", "coverArtUrl?" } }`
+- Internally calls `createRequestForUser(req.user.id, audiobook, { bypassIgnore: true })` — token requests bypass the ignore list, matching UI behavior.
+- Optional query param: `?skipAutoSearch=true` defers search-job creation.
+- Side effects (identical to UI): duplicate detection, library check, Audnexus enrichment, audiobook upsert, ignore-list check (bypassed), per-user dedup, auto-approve gating, release-date gate, notification queue, search-job queue.
+- Auto-approve: follows the token owner's per-user `autoApproveRequests` setting, then global. No bypass.
+- Response: `201 { success: true, request }` or named error: `{ error: "AlreadyAvailable" | "BeingProcessed" | "DuplicateRequest" | "Ignored" | "UserNotFound" | "ValidationError", message }`
+
+## GET `/api/requests/:id`
+- Returns full request including `audiobook`, `downloadHistory` (selected), and recent `jobs`.
+- Ownership enforced: `requestRecord.userId === req.user.id || role === 'admin'` → otherwise 403.
+- Soft-deleted requests (`deletedAt != null`) return 404.
+
+## GET `/api/audiobooks/search`
+- Auth is optional, NOT gated by allowlist (route never calls `requireAuth`).
+- Uses `getCurrentUserAsync` to recognize both JWT sessions AND API tokens for per-user enrichment (request status, ignore status).
+- Without auth: returns generic results with no user-context annotations.
+- With JWT or `rmab_` token: returns results enriched with `isRequested`, `requestStatus`, `requestId`, `isIgnored`, etc.
+
+## Auth flow
+1. Request hits route; `requireAuth` extracts `Authorization: Bearer ...` token.
+2. If token starts with `rmab_` → `authenticateApiToken` (SHA-256 lookup, expiry + soft-delete check, fire-and-forget `lastUsedAt` update).
+3. If on the allowlist → handler runs with `req.user = { sub, id, plexId, username, role }`.
+4. If not on the allowlist → 403 "This endpoint is not available via API token authentication".
+5. JWT tokens skip the allowlist entirely.
+
+## UI surfaces
+- `/api-docs` page (`src/app/api-docs/page.tsx`) — auto-renders `API_TOKEN_ENDPOINT_DOCS`. Endpoints with `isWrite: true` show an amber **Write** badge; the "Try it" button is disabled with a "use curl" hint to avoid sending mutating requests from a UI that cannot construct request bodies.
+- Profile → API Tokens (`src/components/profile/ApiTokensSection.tsx`) — create/revoke UI. Includes a one-line warning that tokens act with the owner's full permissions.
+- Admin → Users → API Tokens — admin can create tokens on behalf of any user.
+
+## Files
+- Constants + matcher: `src/lib/constants/api-tokens.ts`
+- Middleware: `src/lib/middleware/auth.ts` (`requireAuth`, `getCurrentUser`, `getCurrentUserAsync`)
+- Routes:
+ - `src/app/api/user/api-tokens/route.ts` (user create/list/revoke)
+ - `src/app/api/admin/api-tokens/route.ts` (admin)
+- UI: `src/app/api-docs/page.tsx`, `src/components/api-docs/EndpointCard.tsx`, `src/components/api-docs/TokenInput.tsx`, `src/components/profile/ApiTokensSection.tsx`
+
+## Tests
+- `tests/constants/api-tokens.test.ts` — matcher: positive matches, negative matches, sub-route exclusion, method case-insensitivity, allowlist/docs parity.
+- `tests/middleware/auth.middleware.test.ts` — middleware token auth path, allowlist enforcement (incl. dynamic ID match), sibling-route blocking, `getCurrentUserAsync`.
+- `tests/api/requests-id.route.test.ts` — owner GET 200, cross-user GET 403.
+
+## Related
+- [backend/services/auth.md](auth.md) — JWT sessions, role-based access control
+- [backend/services/notifications.md](notifications.md) — request notification triggers
diff --git a/src/app/api-docs/page.tsx b/src/app/api-docs/page.tsx
index 1454c01..f652a92 100644
--- a/src/app/api-docs/page.tsx
+++ b/src/app/api-docs/page.tsx
@@ -131,7 +131,9 @@ export default function ApiDocsPage() {
{/* Footer note */}
- API tokens are restricted to the endpoints listed above.
+ API tokens are restricted to the endpoints listed above. Endpoints
+ flagged Write mutate
+ state on behalf of the token owner — keep your tokens private.
JWT session authentication has access to all endpoints.
diff --git a/src/app/api/audiobooks/search/route.ts b/src/app/api/audiobooks/search/route.ts
index 7412cb2..d61c70d 100644
--- a/src/app/api/audiobooks/search/route.ts
+++ b/src/app/api/audiobooks/search/route.ts
@@ -8,7 +8,7 @@ import { getAudibleService } from '@/lib/integrations/audible.service';
import { enrichAudiobooksWithMatches } from '@/lib/utils/audiobook-matcher';
import { deduplicateAndCollectGroups } from '@/lib/utils/deduplicate-audiobooks';
import { persistDedupGroups, collapseByExistingWorks } from '@/lib/services/works.service';
-import { getCurrentUser } from '@/lib/middleware/auth';
+import { getCurrentUserAsync } from '@/lib/middleware/auth';
import { RMABLogger } from '@/lib/utils/logger';
import { annotateWithIgnoreStatus } from '@/lib/utils/ignored-audiobooks';
@@ -37,8 +37,8 @@ export async function GET(request: NextRequest) {
const audibleService = getAudibleService();
const results = await audibleService.search(query, page);
- // Get current user (optional - for request status enrichment)
- const currentUser = getCurrentUser(request);
+ // Get current user (optional — JWT or API token — for request-status enrichment)
+ const currentUser = await getCurrentUserAsync(request);
const userId = currentUser?.sub || undefined;
// Two-pass dedup: local title/narrator/duration matching first, then collapse
diff --git a/src/components/api-docs/EndpointCard.tsx b/src/components/api-docs/EndpointCard.tsx
index a521dcd..12fae46 100644
--- a/src/components/api-docs/EndpointCard.tsx
+++ b/src/components/api-docs/EndpointCard.tsx
@@ -90,6 +90,11 @@ export function EndpointCard({ endpoint, token, useSession }: EndpointCardProps)
Admin
)}
+ {endpoint.isWrite && (
+
+ Write
+
+ )}