---
title: "Honors surface metadata (categories + milestones)"
method: GET
path: "/api/milestones"
tags: ["Education"]
---

# Honors surface metadata (categories + milestones)

`GET /api/milestones`

## Request

- Base URL: `https://www.oxfordledge.com` (the document declares no server; this is the origin it was published from)
- URL: `https://www.oxfordledge.com/api/milestones`
- Auth: none declared

Return the Honors metadata envelope as
`{categories: [...], total, source}`.

Behavior:
  - PG-populated: returns the categories[] envelope from PG via
    `pg_get_milestones_by_category()`. `source` = "pg";
    `total` = sum of milestones across categories.
  - PG empty / unavailable: falls back to reading
    `static/data/achievements.json` so the surface stays renderable
    during the first-deploy window before the backfill runs.
    `source` = "empty" so the renderer can paint provenance
    accurately. Frontend (achievements.ts) ALSO has its own JSON
    fallback on 5xx (defense-in-depth layer 2).
  - JSON-fallback also fails: returns `{categories: [], total: 0,
    source: "empty"}` so the renderer's `Array.isArray(data.
    categories)` branch short-circuits to the polite empty-state
    copy rather than NPE.

No auth / tier gate — public editorial metadata (same posture as
/api/learning/paths + /api/filers/featured). User-progress data
(localStorage `ol_achievements`) is untouched by this route.

## Response `200`

Successful Response

- MilestonesResponse: `GET /api/milestones` response. Top-level envelope: `{categories: [...], total, source}`. `categories` mirrors the legacy `static/data/achievements.json` `categories[]` shape (each category embeds its `milestones[]` list), so the frontend renderer's swap from JSON to API is payload-shape neutral. `total` is the count of milestones across all categories (the hero reads `body.total` for the "0 / 38 marks earned" copy). `source` is one of `"pg"` (PG-backed) or `"empty"` (no rows backfilled yet / PG unavailable). The renderer can fall back to the static JSON file on `source === "empty"` if it chooses, but the empty-state envelope must still include `categories: []` so the iteration short-circuits to empty-state rather than NPE.
  - `categories` unknown[], required
    - unknown
  - `total` integer, required
  - `source` string, required: 'pg' when categories came from PG, 'empty' otherwise.

## Other responses

- `500`: Internal Server Error

---

[API](https://skmtc.dev/oxfordledge/apis/oxford-ledge-api.md) · [All operations](https://skmtc.dev/oxfordledge/apis/oxford-ledge-api/llms.txt) · [OpenAPI document](https://skmtc.dev/oxfordledge/apis/oxford-ledge-api/revisions/e832a1260f0b?raw)
