---
title: "Counts-only retention preview — same numbers as ops-retention-preview.yml, on demand"
method: GET
path: "/api/v1/admin/retention/preview"
tags: ["admin"]
---

# Counts-only retention preview — same numbers as ops-retention-preview.yml, on demand

`GET /api/v1/admin/retention/preview`

## Request

- Base URL: `https://api.badgeia.com`
- URL: `https://api.badgeia.com/api/v1/admin/retention/preview`
- Auth: none declared

Report prunable-row counts + age distribution without dispatching a workflow.

`.github/workflows/ops-retention-preview.yml` (#652) gives an operator the
same numbers, but it can only be dispatched from a `v*` release tag — the
production GitHub Environment's deployment-branch policy allows nothing
else, so it is correct as a pre-flip gate but heavyweight for routine
checking. This endpoint is the same read, on demand, no workflow dispatch
required.

Provably read-only: the only service calls are `count_prunable` and
`count_row_age_histogram`, both pure ``SELECT count(*) ...`` statements.
Neither touches `delete()`, `prune_all`, or `db.commit()` — this handler
must never import or call either. The handler itself issues no writes, so
`get_db`'s exit-time `await session.commit()` (`database.py`, run on every
successful request regardless of handler) is a no-op here — nothing in the
session is ever dirtied. That safety property lives in the dependency, not
in this function; if a future edit adds a `db.add(...)` here, that commit
stops being a no-op even though this docstring still says "read-only" —
keep the two in sync.

Response mirrors the standalone pruner's dry-run receipt
(`python -m scripts.prune_auth_data`, the same output
`preview-retention-counts.sh` copies into the workflow's Actions log) so
the two are directly comparable:

    {
      "retention_scheduler_enabled": <bool>,
      "prunable": {
        "auth_audit_log": <int>,
        "password_reset_tokens": <int>,
        "email_verification_tokens": <int>,
        "support_messages": <int>,
        "enterprise_waitlist": <int>
      },
      "age_histogram": {
        "auth_audit_log": {"<=7d": <int>, "8-30d": <int>, "31-90d": <int>,
                            "91-180d": <int>, "181-365d": <int>,
                            ">365d": <int>, "total": <int>},
        ... (one row per table above)
      },
      "windows": {
        "auth_audit_log": {"retention_days": <int>},
        "password_reset_tokens": {"grace_days": <int>},
        "email_verification_tokens": {"grace_days": <int>},
        "support_messages": {"retention_days": <int>},
        "enterprise_waitlist": {"retention_days": <int>}
      }
    }

`windows` always reflects the currently configured values, even when a
window is misconfigured (see below) — it is built before either service
call so a bad value is never swallowed by the exception it causes.

If a configured window fails `count_prunable`'s validation (e.g.
`AUTH_AUDIT_RETENTION_DAYS` set below the 7-day safety floor —
unlike `SUPPORT_MESSAGE_RETENTION_DAYS` / `ENTERPRISE_WAITLIST_RETENTION_DAYS`,
it has no startup validator, so a bad value boots fine and only surfaces
here), the response is still 200 with `prunable` and `age_histogram`
omitted and an `error` string added (config names and integers only, no
PII, admin-only audience) — the exact multi-hop-env misconfiguration this
endpoint exists to diagnose must never collapse into an opaque 500.

Counts and configuration only. `age_histogram` buckets ALL rows by age at
day-7-or-coarser granularity (see `count_row_age_histogram`'s docstring for
why buckets, not dates) — it never returns a row's content, email, IP, or
exact timestamp.

Authorization:
    - Anonymous → 401 (auth dep on `require_admin`).
    - Authenticated non-admin → 403 (`require_admin`).
    - Admin → 200 (or 200 + `error`, see above).

Flag gate (rule #4): 404 when `ADMIN_DASHBOARD_ENABLED` is off, matching
every other handler in this router. This does NOT hide the route's
existence — `main.py` serves `/openapi.json` unauthenticated, so the path
and schema are public regardless of flag state (same property already
corrected once on `_require_custody_notice_enabled` above; matching that
language here rather than repeating the mistake). What IS true: FastAPI
resolves `require_admin` before this body runs, so anonymous still gets
401 and a non-admin caller still gets 403 no matter the flag — only an
already-authenticated admin can distinguish 404 from 200, so flag state
is never disclosed below the admin trust level this route already
requires.

## Response `200`

Successful Response

- object

---

[API](https://skmtc.dev/badgeia/apis/badge.md) · [All operations](https://skmtc.dev/badgeia/apis/badge/llms.txt) · [OpenAPI document](https://skmtc.dev/badgeia/apis/badge/revisions/7e7f7fee8b77?raw)
