A read-only API for the WIT course schedule, published by WIT Calendar. 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/v1/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.
The diagram shows the path of a request. The REST controllers and the GraphQL
QueryType both send the filter to Catalog::SectionQuery, which reads the
courses table. REST formats the result with the catalog serializers. GraphQL
formats it with its Types::* classes.
flowchart LR
accTitle: How a catalog request reaches the data
accDescr: The REST controllers and the GraphQL QueryType both use Catalog::SectionQuery, which reads the courses table. REST formats the result with Catalog serializers, and GraphQL with Types classes.
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
- Degree programs from the academic catalog, with their plans of study and elective lists, for each catalog year
- The elective lists that each school publishes for a term
- 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. Cloudflare caches REST responses at the edge for the same hour.
- Rate limit: 300 requests per minute per IP address. A response from the
Cloudflare cache does not count. Its
RateLimitfields are those of the request that filled the cache. - REST page size: 50 by default, 200 maximum.
- GraphQL page size: 50 by default, 200 maximum.
- GraphQL query depth: 12 maximum. Query cost: 500 maximum. See Cost and limits.
- GraphQL has no mutations.
Rate limit headers
Every API response sends two headers from the IETF draft RateLimit header fields for HTTP. Each list item is one policy:
RateLimit-Policy: "req/ip";q=600;w=300, "catalog/ip";q=300;w=60
RateLimit: "req/ip";r=599;t=180, "catalog/ip";r=299;t=42
- In
RateLimit-Policy,qis the number of requests allowed in a window ofwseconds. - In
RateLimit,ris the number of requests left, andtis the number of seconds until the window resets.
When r reaches 0, wait t seconds. A client that goes over a limit gets HTTP
429 with the RATE_LIMITED error code. Wait for the number of seconds in the
Retry-After header before the next request.
Versioning and deprecation
- The major version of the REST API is in the path:
/api/v1. - Version 1 only gets changes that do not break a client: a new field, a new endpoint, a new filter, or a new error code. A client must ignore fields that it does not know.
- A change that breaks a client gets a new path, such as
/api/v2. - Before an endpoint or a field is removed, its responses send a
Deprecationheader (RFC 9745) with the date of the deprecation. They also send aSunsetheader (RFC 8594) with the date of the removal. - The removal comes at least 90 days after the first
Deprecationheader. - A GraphQL field gets
@deprecatedwith a reason at least 90 days before it is removed. - No endpoint and no field is deprecated now.
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.
The diagram shows how to choose a report. To count courses, use
/reports/sections. To count room hours, use /reports/meeting_times.
flowchart LR
accTitle: Which report to use
accDescr: To count courses, use /reports/sections. To count room hours, use /reports/meeting_times.
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" }
Every 4xx and 5xx response has this body. Branch on code, not on the
message:
| Code | HTTP status | Meaning |
|---|---|---|
INVALID_FILTER |
400 | A filter value is not valid |
NOT_FOUND |
404 | No record matches |
RATE_LIMITED |
429 | The client went over a rate limit. The body also has retry_after. |
INTERNAL_ERROR |
500 | An unexpected server error |
Endpoints
| Method and path | Description |
|---|---|
GET /api/v1/catalog/terms |
All terms, newest first. ?active=true lists only the terms in session today |
GET /api/v1/catalog/terms/current |
The current term. 404 when there is none |
GET /api/v1/catalog/terms/next |
The term after the current term. 404 when there is none |
GET /api/v1/catalog/terms/:uid |
One term by its Banner code, e.g. 202710 |
GET /api/v1/catalog/terms/:uid/electives |
The elective lists that each school publishes for the term |
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/sections/:crn/similar |
Sections like this one |
GET /api/v1/catalog/instructors/:pub_id |
One instructor |
GET /api/v1/catalog/instructors/:pub_id/similar |
Instructors like this one |
GET /api/v1/catalog/reviews |
Rate My Professors reviews |
GET /api/v1/catalog/courses |
Courses (not sections), by code or title words |
GET /api/v1/catalog/courses/:code |
One course: description, prerequisites, corequisites, restrictions, mutual exclusions, catalog entry, fees, learning objectives, what it unlocks |
GET /api/v1/catalog/schedule_check |
Check a set of sections taken together |
GET /api/v1/catalog/programs |
Degree programs of one catalog year |
GET /api/v1/catalog/programs/:slug |
One program: its plans of study and elective lists |
GET /api/v1/catalog/subjects accepts term_uid.
GET /api/v1/catalog/schedule_check needs term_uid and crn, a
comma-separated list of at most 30 CRNs, labs included. It returns time
conflicts (the same day, overlapping times, overlapping dates), linked
lecture and lab pairs that are missing a partner, the same course chosen
twice, and totals: credit hours, days, earliest start, latest end, and the
number of meetings before 9:00. Credit hours use Banner's credits for linked
sections, where a lab is 0.
GET /api/v1/catalog/instructors accepts term_uid, q, semantic, page,
and per_page. Without semantic, each word of q must match the first, last,
or display name, so q=ada lovelace finds Ada Lovelace.
GET /api/v1/catalog/courses accepts q (a code such as COMP 2350, or title
words), subject, term_uid (only courses with a section in that term), page,
and per_page.
GET /api/v1/catalog/courses/:code takes COMP2350 or COMP 2350, and an
optional term_uid that adds the course's sections in that term. The
prerequisites are an and/or tree of courses and placement tests. Each course in
the tree has its title and linked: true when WIT Calendar knows the course.
unlocks lists the courses that name this one as a prerequisite.
mutual_exclusions lists the courses that block this one: a student who
completed one of them cannot take it. The course also has its college,
division, department, levels, grading_modes, fees (each with
amount_cents), and learning_objectives from Banner's syllabus page. The data
comes from Banner and is refreshed every night. Each section also has a course
object with the description, the prerequisites, and typically_offered.
A section has its part_of_term (1 for the full term, a code such as FA1 or
FA2 for one half), its campus, and its cross_list: the sections of other
courses that meet with it, with the seats of the whole group. cross_list is
null when the section is not cross-listed.
GET /api/v1/catalog/programs accepts catalog_year (2024-2025, 2024-25,
2024, or a term name such as Spring 2024, which is in 2023-2024),
kind (associate, bachelor, master, minor, concentration,
certificate, exploratory), level (undergraduate, graduate), and q
(words in the name). Without catalog_year, it uses the newest catalog year.
meta.catalog_years lists the catalog years the API has, newest first. The data
starts with 2019-2020. The list leaves out a program that the catalog no longer
lists. The program endpoint still returns it, with listed: false.
GET /api/v1/catalog/programs/:slug takes the slug from the list, such as
computer-science-bs, or the exact program name, and the same catalog_year. Each plan of study lists
its terms by year and season. Each item is a course, a choice between
the courses in options, or a placeholder such as "Computer Science
Elective", with the name of the elective_list it draws from. The data comes
from catalog.wit.edu and is refreshed each month. It is the published catalog,
not a degree audit.
GET /api/v1/catalog/terms/:uid/electives returns one item for each elective
list PDF on the Registrar's "Technical, Major, and HSS Electives" page: the
school (SCDS, HSS, SOM, ARCH, SOE, SOS, or null), the title, the url of the
PDF, fetched_at, the courses, and the full text of the PDF. Each course has
its code, its Banner title, and the heading above it in the PDF, such as
"Computer Science Elective". The text has the rules that name no course, such
as "Any 2000-Level courses in BIOE, BIOL, ...". It accepts school (one code or
a comma-separated list), course (a code such as COMP2000), and q (words
that must all be in the heading). With a filter, each list keeps only the
matching courses, and a list with no match is left out. The data is refreshed
each night.
GET /api/v1/catalog/instructors/:pub_id also returns the Rate My Professors
rating distribution, the top tags, and the newest reviews. Add term_uid to list
the sections the instructor teaches in that term.
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 |
semantic |
true |
Rank q by meaning instead of by the literal words |
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.
Search by meaning
q matches the literal words. Add semantic=true to rank by meaning instead,
so intro to programming reaches Computer Science I. The same switch works
on /api/v1/catalog/instructors.
curl "https://calendar.witcc.dev/api/v1/catalog/sections?q=learn+to+program&semantic=true&term_uid=202710"
Points to know:
- Results come back ranked, nearest first, not sorted by subject and number.
- Every other filter still applies. The ranking covers what the filters left.
- A section stays out until it has been embedded, which happens nightly.
- Semantic requests are limited to 30 per minute per IP. The keyword search keeps the standard 300 per minute. A GraphQL query that runs a semantic search uses the same 30 per minute.
- A GraphQL query can run one semantic search. A query that asks for more gets
the error code
TOO_MANY_SEMANTIC_SEARCHESand runs nothing. - The server falls back to the keyword search when semantic search is off. The request never fails because of it.
Reviews
GET /api/v1/catalog/reviews returns Rate My Professors reviews of WIT
instructors, newest first. Only reviews that carry a comment are returned.
| Filter | Example | Effect |
|---|---|---|
instructor |
fac_kw7coe30 |
Keep one instructor's reviews |
q |
group projects |
Search the comment and the course name |
semantic |
true |
Rank q by meaning instead of by the literal words |
sentiment |
positive |
Keep only positive or only negative reviews |
page, per_page |
2, 50 |
Page through the result. 25 by default, 100 at most |
curl "https://calendar.witcc.dev/api/v1/catalog/reviews?q=lots+of+group+projects&semantic=true"
The text of a review belongs to the student who wrote it on
ratemyprofessors.com. Every review, and the meta of every page, names that
source. Credit it and link back when you show this text.
What is like this one?
/similar ranks the records closest in meaning to one record, nearest first.
curl "https://calendar.witcc.dev/api/v1/catalog/sections/17294/similar?limit=5"
curl "https://calendar.witcc.dev/api/v1/catalog/instructors/fac_kw7coe30/similar"
Points to know:
limitis 10 by default and 50 at most.- Similar sections stay inside the section's own term, and the other sections of the same course are left out.
- Similar instructors are people who teach at least one section.
- The list is empty until the record has been embedded, which happens nightly.
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/v1/graphql with a JSON body:
curl -X POST https://calendar.witcc.dev/api/v1/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, semantic, plus Relay arguments |
A connection of faculty |
reviews |
instructor, q, semantic, sentiment, plus Relay arguments |
A connection of reviews |
SectionType.similar(limit:) and InstructorType.similar(limit:) return the
records closest in meaning to that record. Both cost more than a plain field,
because each one runs its own search.
sections and instructors are Relay connections. Both add totalCount, so a
client can show "50 of 1174" without a second request.
Cost and limits
The schema describes the cost of a query and its limits with directives. A
client can read them from /docs/api/schema.graphql and check a query before
it sends it.
| Directive | Where | Meaning |
|---|---|---|
@cost(weight: "1") |
Every field | The field adds 1 to the cost of a query. |
@listSize(slicingArguments: ["first", "last"], sizedFields: ["edges", "nodes"], assumedSize: 200, requireOneSlicingArgument: false) |
sections, instructors |
first or last sets the length of edges and nodes. A page never has more than 200 items. |
@queryLimits(maxCost: 500, maxDepth: 12, defaultPageSize: 50, maxPageSize: 200) |
The schema | The server rejects a query that costs more than maxCost or nests deeper than maxDepth. |
@rateLimit(max: 300, window: 60) |
The schema | One IP address can send at most 300 requests in 60 seconds. REST and GraphQL share this limit. |
@cost and @listSize come from the
IBM GraphQL Cost Directives specification.
No published standard defines @queryLimits or @rateLimit.
The server computes the cost of each query:
- Each field adds 1.
- In a connection, each field inside
edgesandnodescounts once for each item on the page. The page size isfirstorlast, or 50 when the query gives neither. - A plain list, such as
termsormeetingTimes, counts its fields once. __typenameadds 0.
The directives do not describe the cost of an introspection query (__schema
or __type).
A cost analysis that reads @cost and @listSize gets the server's cost or a
higher one. Without first or last, the analysis must assume a page of 200
items. Give first to get a closer estimate.
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. From the time a term's sections are listed until 7 days after its classes start, a job also refreshes them every 30 minutes. Outside that window they can be up to a day old. Do not show them as a real-time seat count.waitlist.capacityandwaitlist.availableare the section's waitlist places and the places still open, from the same Banner data and jobs asseats. A section with no waitlist has a capacity of 0. 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. Whenlinked.sourceisbanner, the pairs come from Banner's own linked sections, fetched for each linked section. Before that,sourceislink_identifier: a guess that pairs a lectureA1with every labB1of the same course. The guess can be wrong.linked.optionslists the sets of partners: take every CRN of any one set.linked.crnslists all partners, andlinked.pub_idsnames them by public id, in the same order.linked.requiredisfalseand the 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