Public Catalog API
A read-only API for the WIT course schedule. It needs no authentication and no API key.
This reference is also available as markdown at /docs/api.md. A client that
sends Accept: text/markdown to /docs/api gets the same markdown.
/llms.txt lists the main entry points for AI agents.
Machine descriptions of the API are also available:
| URL | Content |
|---|---|
/.well-known/api-catalog |
An RFC 9727 API catalog. It links each API to its description, this reference, and a health check. |
/docs/api/openapi.json |
An OpenAPI 3.1 description of the REST endpoints |
/docs/api/schema.graphql |
The GraphQL schema in SDL |
The API has three surfaces over the same data:
| Surface | Endpoint | Use it for |
|---|---|---|
| REST | GET /api/v1/catalog/... |
Simple requests, caching, curl |
| GraphQL | POST /api/graphql |
One request for nested data |
| CSV reports | GET /reports/... |
Excel and Power BI, no code |
Both surfaces use the same filter object, Catalog::SectionQuery. A filter
gives the same result on both.
flowchart LR
R[REST controllers] --> Q[Catalog::SectionQuery]
G[GraphQL QueryType] --> Q
Q --> DB[(courses)]
R --> S[Catalog serializers]
G --> T[Types::*]
What the API contains
The API contains course schedule data only:
- Terms, subjects, and course sections
- Meeting days, times, buildings, and rooms
- Instructors, with Rate My Professors scores
- Final exam times
The API contains no user data. It does not return student records, grades, or enrollments. It does not return instructor email addresses, phone numbers, or office locations.
Limits
- Responses are public and cacheable for 1 hour.
- Rate limit: 300 requests per minute per IP address.
- REST page size: 50 by default, 200 maximum.
- GraphQL page size: 50 by default, 200 maximum.
- GraphQL query depth: 12 maximum. Query complexity: 500 maximum.
- GraphQL has no mutations.
CSV reports
Three URLs answer with a CSV body. Open one in Excel with Data > From Web, or point Power BI at it. They need no code and no key, and they refresh in place. They carry the same cache headers as the rest of the API.
| Report | Grain | One row is |
|---|---|---|
GET /reports/sections?term_uid=<uid> |
section | one course section |
GET /reports/meeting_times?term_uid=<uid> |
meeting time by room | one meeting, in one room, on one day |
GET /reports/terms |
term | one term that has schedule data |
term_uid is optional on the first two. Omit it to get every term at once.
GET /reports/terms lists the valid values.
Which grain do I want?
Start with sections. It is the grain most people expect: a section appears
once, the way it appears on a schedule.
crn subject course_number section_number meeting_days meeting_times room
16036 Mathematics (MATH) 2300 3 TR 10:00-11:45 WENTW 214
17309 Mathematics (MATH) 1525 1A MW 08:00-09:15 WENTW 206
Use meeting_times when the question is about a room or an hour, such as
"what is in WENTW 206 on Tuesday at 08:00". There, a section that meets twice
a week is two rows, one per day. That is the right shape for a room question
and the wrong shape for a course list: a reader scanning it sees each course
more than once and reads the extra rows as duplicates.
flowchart LR
Q{What are you counting?} -->|courses| S["/reports/sections"]
Q -->|room hours| M["/reports/meeting_times"]
meeting_count on a section row says how many meeting_times rows collapsed
into it, so the two reports reconcile.
Reading the meeting columns
meeting_days uses Banner's day codes, starting on Monday. R is Thursday
and U is Sunday, because T and S are already taken.
| Code | M | T | W | R | F | S | U |
|---|---|---|---|---|---|---|---|
| Day | Mon | Tue | Wed | Thu | Fri | Sat | Sun |
Most sections meet on one pattern, so meeting_days reads as MW and
meeting_times as 08:00-09:15. A section with more than one pattern, such
as a lecture that also has a Friday lab in another room, lists one part per
pattern separated by ;. The parts line up across every meeting column:
meeting_days meeting_times room
MW; F 09:00-10:15; 13:00-14:50 ANX 305; DOB 005
Faculty names inside a single cell are separated by ,, so the two
separators never collide.
room_capacity on a section row is the largest room the section is scheduled
into. It comes from 25Live and is blank when 25Live has no capacity for the
room. It is not an official seat count for the room.
REST
Response shape
A collection returns data and meta:
{
"data": [ ... ],
"meta": { "page": 1, "per_page": 50, "total_count": 1174, "total_pages": 24 }
}
A single record returns data only.
An error returns error and code:
{ "error": "Unknown day \"funday\"", "code": "INVALID_FILTER" }
The code is INVALID_FILTER (HTTP 400) or NOT_FOUND (HTTP 404).
Endpoints
| Method and path | Description |
|---|---|
GET /api/v1/catalog/terms |
All terms, newest first |
GET /api/v1/catalog/terms/:uid |
One term by its Banner code, e.g. 202710
|
GET /api/v1/catalog/subjects |
Subjects with section counts |
GET /api/v1/catalog/sections |
Sections, with filters |
GET /api/v1/catalog/sections/:crn |
One section by CRN |
GET /api/v1/catalog/instructors |
Faculty who teach at least one section |
GET /api/v1/catalog/instructors/:pub_id |
One instructor |
GET /api/v1/catalog/subjects accepts term_uid.
GET /api/v1/catalog/instructors accepts term_uid, q, page, and
per_page.
GET /api/v1/catalog/sections/:crn accepts term_uid. Use it when one CRN
occurs in more than one term. This endpoint also returns cancelled sections, so
a saved CRN does not disappear without an explanation.
Section filters
All filters are optional. Give a list as a comma-separated value, for example
subject=COMP,MATH.
| Filter | Example | Effect |
|---|---|---|
term_uid |
202710 |
Keep sections in this term |
subject |
COMP |
Match the code or the full label |
course_number |
1000 |
Keep these course numbers |
crn |
10001,10002 |
Keep these CRNs |
pub_id |
crs_kw7coe30 |
Keep these sections by public id |
q |
algorithms |
Search the title, subject, and number |
schedule_type |
lecture or LEC
|
Keep these schedule types |
credit_hours |
4 |
Keep these credit hours |
instructor |
byron |
Match the instructor name |
meets_on |
monday,friday |
Keep sections that meet on any of these days |
free_days |
friday |
Drop sections that meet on any of these days |
begins_after |
10:00 |
Drop sections with any meeting before this time |
ends_before |
16:00 |
Drop sections with any meeting after this time |
include_cancelled |
true |
Add cancelled sections |
page, per_page
|
2, 100
|
Page through the result |
begins_after and ends_before accept HH:MM or HHMM. Both are inclusive at
the boundary.
free_days, begins_after, and ends_before apply to the whole section. A
section is dropped if any of its meetings breaks the rule. This is what a
student wants: one Friday afternoon lab still ruins a free Friday.
An unknown filter value returns HTTP 400. An unknown query parameter is ignored.
Example
Find Computer Science sections in Fall 2026 that keep Friday free and do not start before 10:00:
curl "https://calendar.witcc.dev/api/v1/catalog/sections?term_uid=202710&subject=COMP&free_days=friday&begins_after=10:00"
GraphQL
Send a POST request to /api/graphql with a JSON body:
curl -X POST https://calendar.witcc.dev/api/graphql \
-H "Content-Type: application/json" \
-d '{"query": "{ terms { uid name sectionCount } }"}'
Send variables as JSON, not as strings. A form-encoded request turns every value into a string, and GraphQL then rejects booleans and numbers.
Queries
| Query | Arguments | Returns |
|---|---|---|
terms |
— | All terms, newest first |
term |
uid |
One term |
subjects |
termUid |
Subjects with section counts |
sections |
filter, plus Relay arguments |
A connection of sections |
section |
crn, termUid
|
One section, cancelled ones included |
instructors |
termUid, q, plus Relay arguments |
A connection of faculty |
sections and instructors are Relay connections. Both add totalCount, so a
client can show "50 of 1174" without a second request.
The filter argument takes the same filters as REST, in camelCase. Days and
schedule types are enums, for example FRIDAY and LECTURE, so a wrong value
fails at validation time.
Example
One request for a section and everything attached to it:
{
section(crn: 17294) {
courseCode
title
creditHours
term { uid name }
meetingTimes {
day
beginTime
endTime
durationMinutes
location { display building { abbreviation name } rooms { number floor } }
}
instructors { name rmp { avgRating numRatings } }
linked { required identifier crns pubIds }
finalExam { date startTime endTime location }
}
}
Filter with variables:
query Sections($filter: SectionFilterInput) {
sections(filter: $filter, first: 50) {
totalCount
pageInfo { hasNextPage endCursor }
nodes { crn courseCode title }
}
}
{ "filter": { "termUid": 202710, "subject": ["COMP"], "freeDays": ["FRIDAY"] } }
Notes on the data
-
seats.capacityandseats.availablecome from Banner. The catalog import sets them, and a nightly job refreshes them for every active term. They can be up to a day old, so do not show them as a real-time seat count. Responses also carryCache-Control: public, max-age=3600, which adds at most one more hour. Either field isnullwhen Banner did not return enrollment data for the CRN. -
linkedsays which sections a student must register together, most often a lecture and its lab. It comes from Banner's ownlinkIdentifier, which reads as<slot><key>: the first character is the slot (Afor the lecture,Bfor the lab) and the rest is the key that pairs them. A lectureA1goes with every labB1of the same course.linked.crnslists the partners in the same term, so a client does not have to work the pairing out from the section number.linked.pub_idsnames the same partners by public id, in the same order.linked.requiredisfalseand both lists are empty for a section that stands alone. -
pub_idis the id to store. It is unique across every term and it never changes, so it is the key to use when you join this catalog to another system. A CRN is only unique inside one term, and the registrar reuses it. Both are filters:pub_idneeds no term,crnnormally does. Apub_idthat does not decode is a 400, not an empty result. -
locationisnullwhen a section has no room, as with online sections.location.displayisnullwhen the room is a placeholder "TBD" record. -
rmpisnullwhen an instructor has no ratings. Rate My Professors reports "no data" as0for an average and-1for a percentage. The API removes these sentinel numbers so a client never shows them as real scores. - Duplicate meeting rows from a repeated import are collapsed. The copy with a real room wins.
Dump the GraphQL schema
/docs/api/schema.graphql returns the schema. To dump it from a checkout:
bin/rails runner 'puts CatalogSchema.to_definition' > catalog.graphql