# Upsked Catalog API (machine-readable)

> Copy this entire document into ChatGPT, Claude, Cursor, or any coding agent.

## Quick reference

| Item | Value |
|------|-------|
| **API base URL** | `https://upsked.com/api/public/v1` |
| **Auth header** | `Authorization: Bearer upsked_api_v1_...` |
| **Scope** | `catalog:read` |
| **Get a key** | https://upsked.com/?settings=account, open Public REST API |
| **Endpoints** | `/universities` `/semesters` `/courses` `/sections` |
| **Interactive docs** | https://upsked.com/developers |
| **MCP (Cursor / Claude)** | https://upsked.com/developers-mcp.md |
| **Discovery index** | https://upsked.com/llms.txt |
| **Subdomain alias** | `https://api.upsked.com` (optional; same handlers) |

> Prefer **MCP** inside Cursor or Claude Desktop for live tools without a Bearer token in config. Use this REST doc for server integrations and scripts.

---

# Catalog API

Upsked provides normalized course catalog data from supported universities through a simple REST API.

We turn school-specific course, section, and enrollment structures into predictable records: searchable courses, schedulable class options, linked lectures and labs, and bundled classes that must be taken together.

Pick a school and term, search courses, then fetch the valid class options a student can add to a schedule.

Use it to power school and term pickers, course search, enrollment assistants, schedule builders, and other tools that need trustworthy catalog data, without maintaining per-school scrapers. Integrate with REST (this document) or MCP (`/developers-mcp.md`).

Human reference: **`/developers`**. Machine-readable references: **`/developers.md`**, **`/llms.txt`**, **`/developers-mcp.md`**.

## The model

Courses are searchable. Options are schedulable.

A course tells you what exists in a term. A schedulable option tells you what a student can add to a schedule. Some options are standalone sections. Others are linked lecture-lab pairs or school-defined blocks that must be taken together.

That is why the API separates `/courses` from `/sections`.

1. Choose a school with `/universities`.
2. Choose a term with `/semesters`.
3. Search courses in that term with `/courses`.
4. Fetch valid class options with `/sections`.

**IDs to pass between calls:**

- `university_id`: school slug (`upd`, `admu`, `dlsu`, …)
- `semester_id`: term (required for `/courses` and `/sections`)
- `course_id`: from `/courses`, used by `/sections`
- `class_code`: use instead of `course_id` when you already have CRS codes

> Keep public apps safe: call the API from your server when you deploy. The browser console on this page is only for trying requests while signed into Upsked.

## Quickstart

**Goal:** show a student which class sections they can pick for a course.

1. **Get a key:** open [Account settings](https://upsked.com/?settings=account), then **Public REST API** (`catalog:read`).
2. **List schools:** `GET /universities`
3. **List terms:** `GET /semesters?university=upd`, then copy a `semester_id`
4. **Search courses:** `GET /courses?semester_id=<id>&query=math`, then copy a `course_id`
5. **Get sections:** `GET /sections?semester_id=<id>&course_id=<course_id>`

Every request needs this header:

```http
Authorization: Bearer upsked_api_v1_YOUR_KEY
```

Base URL: **`https://upsked.com/api/public/v1`**

```bash
curl -sS 'https://upsked.com/api/public/v1/semesters?university=upd' \
  -H 'Authorization: Bearer upsked_api_v1_YOUR_KEY'
```

- Do the steps in order. You need a `semester_id` before `/courses` or `/sections`.
- If `section_count` is `0`, skip `/sections` for that course.
- For `/sections`, send `course_id` **or** `class_code`, not both.

## Authentication

```http
Authorization: Bearer upsked_api_v1_...
```

Create keys in [Account settings](https://upsked.com/?settings=account), open **Public REST API**. Keys can expire after 1, 7, or 30 days. You can reveal, refresh, or revoke them from Account settings.

Do not put API keys in public JavaScript, mobile apps, or browser extensions. If classmates can view your source, they can copy the key.

## Base URLs

| Base | Notes |
|------|-------|
| **`https://upsked.com/api/public/v1`** | Default. Always available. |
| `https://api.upsked.com` | Optional subdomain alias (same handlers) |
| `http://localhost:3000/api/public/v1` | Local development |

Paths: `/universities`, `/semesters`, `/courses`, `/sections`.

## Conventions

- JSON uses **snake_case**.
- Lists: `{ "object": "list", "type": "<kind>", "results": [ ... ] }`.
- Section responses use `options` because one class choice can include multiple linked sections.
- Errors: `{ "object": "error", "code": "...", "message": "..." }`.
- Validation **400** may include `errors: [{ "param", "message" }]`.
- Pagination uses `limit`, `offset`, `has_more`, and `next_offset`.
- Times are local school times in `HH:mm` format.

### Error codes

| HTTP | Code | When |
|------|------|------|
| 400 | `bad_request` | Invalid params, unknown `semester_id` or `university` |
| 401 | `unauthorized` | Missing or malformed Authorization header |
| 401 | `invalid_api_key` | Revoked, expired, or invalid key |
| 404 | `not_found` | Unknown `course_id`, or no matching `class_code` |
| 429 | `rate_limited` | Per-IP or per-account rate cap |
| 200 | (none) | Partial `class_code` match returns `missing_class_codes` |

## Rate limits

Responses may include:

- `X-RateLimit-Limit`
- `X-RateLimit-Remaining`
- `X-RateLimit-Reset`

**429:** `{ "object": "error", "code": "rate_limited", "message": "..." }`

Use pagination and `ETag` on `/sections` instead of tight polling. If you receive a 429, wait until the reset time before retrying.

## Pitfalls

| Mistake | Fix |
|---------|-----|
| Calling `/courses` before choosing a term | Call `/semesters` first and pass the returned `semester_id`. |
| Treating `section_count` as a section list | Use `section_count` only to decide whether to call `/sections`. |
| Calling `/sections` with both `course_id` and `class_code` | Pass exactly one of them. |
| Assuming every school supports every feature | Read `capabilities` from `/universities` before search or sections. |
| Caching section pages without query params | Include `semester_id`, `course_id` or `class_code`, `limit`, and `offset` in cache keys. |

## Changelog

### May 26, 2026

The Catalog API is now generally available through the Public REST API. Create an API key from your Upsked account to search courses, load section schedules, and build tools for supported universities.

---

# Endpoint reference

## GET /universities
Returns the schools available in Upsked. Start here when your app needs a school picker.
No query parameters.

### Response fields

| Field | Description |
|-------|-------------|
| `object` | `university` for each school row. |
| `university_id` | School slug such as `upd`, `admu`, or `dlsu`. Use it as `university` in GET /semesters. |
| `label` | School name to show in a picker. |
| `status` | `active` means generally available. `pilot` means coverage may still be limited. |
| `capabilities` | Tells you which catalog features work for the school, such as `catalog_course_search` and `catalog_sections`. |
| `semester_id_hint` | Example id shape only. Use GET /semesters for real term ids. |

- Do not hard-code the school list. New schools can appear without a docs change.
- Call GET /semesters next with the selected `university_id`.
### Example response

```json
{
  "object": "list",
  "type": "university",
  "results": [
    {
      "object": "university",
      "university_id": "admu",
      "label": "Ateneo de Manila University",
      "status": "active",
      "capabilities": ["catalog_course_search", "catalog_sections"],
      "semester_id_hint": "admu-25262"
    }
  ]
}
```

## GET /semesters
Returns terms for a school. Pick a `semester_id` before searching courses or sections.
| Parameter | Required | Description |
|-----------|----------|-------------|
| `university` | no | A `university_id` from GET /universities. Omit it only if you are building your own cross-school term list. |

### Response fields

| Field | Description |
|-------|-------------|
| `semester_id` | Term id required by GET /courses and GET /sections. |
| `label` | Term name to show in a picker. |
| `term` | School-specific term code. |
| `year_start / year_end` | Academic year bounds. |
| `is_current` | True when this is the recommended term for the school. |
| `university_id` | School slug for the term. |
| `default_semester_id` | Recommended default when `university` is provided. Null on a global list. |
| `default_semester_ids_by_university` | Map of school slug to default term id. Present on the global list only. |

- For a student-facing UI, pass `university` so the term picker stays school-specific.
- Unknown `university` returns 400.
### Example response

```json
{
  "object": "list",
  "type": "semester",
  "default_semester_id": "admu-25262",
  "default_semester_scope": "university",
  "university_id": "admu",
  "results": [
    {
      "object": "semester",
      "semester_id": "admu-25262",
      "label": "Second Semester AY 2025-2026",
      "term": "2",
      "year_start": 2025,
      "year_end": 2026,
      "is_current": true,
      "university_id": "admu"
    }
  ]
}
```

## GET /courses
Search courses in one term. Use the returned `course_id` to load class options.
| Parameter | Required | Description |
|-----------|----------|-------------|
| `query` | yes | What the student typed. Matches course code and title. |
| `semester_id` | yes | A `semester_id` from GET /semesters. |
| `limit` | no | Number of course matches to return. Minimum 1, maximum 20, default 8. |
| `offset` | no | Pagination offset. Minimum 0, maximum 400, default 0. Use `next_offset` when `has_more` is true. |

### Response fields

| Field | Description |
|-------|-------------|
| `course_id` | Course lookup value. Pass it to GET /sections with the same `semester_id`. |
| `title` | Course title as published by the school catalog. |
| `section_count` | How many class options exist for this course in the selected term. If 0, skip GET /sections. |
| `pagination.has_more` | True when another page is available. |
| `pagination.next_offset` | Offset to pass on the next request. Null when there is no next page. |
| `pagination.ranking_truncated` | True when the ranked candidate pool hit the server cap. Narrow the query for more precise results. |

### Example response

```json
{
  "object": "list",
  "type": "course",
  "semester_id": "120252",
  "university_id": "upd",
  "results": [
    {
      "object": "course",
      "course_id": "EEE 141",
      "title": "Electrical Engineering 141",
      "section_count": 4
    }
  ],
  "pagination": {
    "limit": 8,
    "offset": 0,
    "has_more": false,
    "next_offset": null,
    "ranking_truncated": false
  }
}
```

## GET /sections
Returns the actual class options a student can put on a schedule.
| Parameter | Required | Description |
|-----------|----------|-------------|
| `semester_id` | yes | A `semester_id` from GET /semesters. |
| `course_id` | xor | A `course_id` from GET /courses. Pass one course at a time. |
| `class_code` | xor | Comma-separated class codes when the student already knows exact portal or CRS codes. Maximum 24 per request. |
| `limit` | no | Number of class options to return. Minimum 1, maximum 20, default 20. |
| `offset` | no | Pagination offset. Minimum 0, maximum 500, default 0. |

### Response fields

| Field | Description |
|-------|-------------|
| `option_type` | `standalone`, `block` for same-course bundles, or `linked` for cross-course bundles. |
| `bundle_id` | Present when multiple classes must be taken together. |
| `bundle.class_codes` | Class codes included in the bundle. |
| `sections[]` | Classes included in this option. Bundles have more than one row. |
| `sections[].schedules[]` | Meetings with `day`, `time_start`, `time_end`, `room`, and `class_type`. |
| `sections[].remarks` | Free-text catalog notes from the school registrar (e.g. delivery mode or slot wording). `null` when none. Informational only—not enrollment instructions. |
| `missing_class_codes` | Class codes not found when using `class_code`. Partial matches still return 200 with the classes that were found. |
| `pagination` | `limit`, `offset`, `has_more`, and `next_offset` for paging options. |

### Example response

```json
{
  "object": "list",
  "type": "schedulable_option",
  "semester_id": "120252",
  "university_id": "upd",
  "options": [
    {
      "object": "schedulable_option",
      "option_type": "standalone",
      "sections": [
        {
          "object": "section",
          "course_id": "EEE 141",
          "class_code": "52506",
          "section_code": "WXY",
          "schedules": [
            {
              "day": "M",
              "time_start": "09:00",
              "time_end": "10:30",
              "room": "EE Institute",
              "class_type": "lec"
            }
          ],
          "remarks": null
        }
      ]
    }
  ],
  "pagination": {
    "limit": 20,
    "offset": 0,
    "has_more": false,
    "next_offset": null
  }
}
```
