diff --git a/.gitignore b/.gitignore index aaadf73..aa1c97f 100644 --- a/.gitignore +++ b/.gitignore @@ -30,3 +30,13 @@ go.work.sum # Editor/IDE # .idea/ # .vscode/ + +# Compiled server binaries. These were committed for a while; do not let them +# back in -- they are ~53MB together and are rebuilt by every `go build`. +/jupiterp-api +/gin + +# macOS and Python noise +.DS_Store +__pycache__/ +*.py[cod] diff --git a/cache.go b/cache.go index 1201abd..2a266a7 100644 --- a/cache.go +++ b/cache.go @@ -7,7 +7,23 @@ import ( "time" ) -const defaultCacheCapacity = 124 +// Entries held per cache. +// +// This was 124, sized for a key space of department prefixes and a handful of +// course codes. Professor pages change that shape entirely: a per-slug key for +// every professor, plus a per-professor grade summary and a per-professor term +// series. At 124 entries that space thrashes to a near-zero hit rate and every +// request reaches Supabase, which is the opposite of what the cache is for. +const defaultCacheCapacity = 4096 + +// Course search runs on every page load and is the hottest path in the API. +// It gets its own cache so that a burst of professor-page traffic - which has +// a much larger key space - cannot evict it. +// +// The caches are per Cloud Run instance and there is no cross-instance +// invalidation; that is fine for data whose TTL is measured in hours, and is +// the reason review reads will need a much shorter TTL when they arrive. +const courseCacheCapacity = 2048 type cachedPayload struct { status int diff --git a/config.go b/config.go new file mode 100644 index 0000000..da8f727 --- /dev/null +++ b/config.go @@ -0,0 +1,311 @@ +package main + +import ( + "log" + "os" + "strconv" + "strings" + "time" +) + +// Config holds everything the write path needs from the environment. +// +// Every value is read once at boot and validated there rather than at first +// use. A misconfigured moderation pipeline that only reveals itself when the +// first review arrives is a pipeline that is broken during exactly the window +// nobody is watching it. +type Config struct { + // Existing, read path. + DatabaseURL string + DatabaseKey string + Port string + + // Write path. Empty ServiceKey disables /v1 entirely. + ServiceKey string + EmailPepper string + AdminKey string + // Named moderator keys, as "alice:key1,bob:key2". + // + // The audit trail records `decided_by` for every decision, but with one + // shared key that field could only ever say "human" -- it could not say + // which human approved a review about a named professor, or which one + // merged two identities irreversibly. That is the question an audit trail + // exists to answer. + // + // Optional and additive: REVIEW_ADMIN_KEY keeps working unchanged and is + // recorded as "human", so nothing breaks by not setting this. + ModeratorKeys map[string]string + TurnstileKey string + BrevoAPIKey string + EmailFrom string + EmailFromName string + SiteBaseURL string + AllowedOrigins []string + + // Domains permitted to submit. Both terpmail and umd.edu, so that faculty + // and staff can review too; `email_domain` is stored so those can be + // identified or labelled later if that turns out to matter. + AllowedEmailDomains []string + + // Automated triage. Everything here is optional: with TriageWebhookURL + // empty, every verified review goes to the human queue and the system is + // fully functional. That property is worth testing by actually running + // with it empty rather than assuming. + TriageWebhookURL string + TriageWebhookSecret string + TriageCallbackKey string + TriageTimeout time.Duration + TriageRetryMax time.Duration + TriageMaxAttempts int + TriageDisabled bool + DiscordWebhookURL string + + // Shadow mode gates. Both false means the classifier writes its opinion to + // moderation_decisions with applied = false and a human decides + // everything. That is stage one and it is the default. + AutoReject bool + AutoApprove bool + AutoApproveMinConf float64 + AutoRejectMinConf float64 + + // If true, the deterministic pre-filter may reject a review outright -- + // links, email addresses, phone numbers -- with no human in the loop. + // + // Separate from AutoReject, which gates the classifier. This gates a rule, + // and a rule is worth trusting further than a model: it does not vary and + // cannot be argued out of its conclusion by the text it is reading. It is + // still a switch, so that "shadow mode" can mean what it says. + PrefilterAutoReject bool +} + +func LoadConfig() *Config { + c := &Config{ + DatabaseURL: mustEnv("DATABASE_URL"), + DatabaseKey: mustEnv("DATABASE_KEY"), + Port: envOr("PORT", "8080"), + + ServiceKey: os.Getenv("DATABASE_SERVICE_KEY"), + EmailPepper: os.Getenv("REVIEW_EMAIL_PEPPER"), + AdminKey: os.Getenv("REVIEW_ADMIN_KEY"), + ModeratorKeys: parseModeratorKeys(os.Getenv("REVIEW_MODERATOR_KEYS")), + TurnstileKey: os.Getenv("TURNSTILE_SECRET_KEY"), + BrevoAPIKey: os.Getenv("BREVO_API_KEY"), + EmailFrom: os.Getenv("EMAIL_FROM_ADDRESS"), + EmailFromName: envOr("EMAIL_FROM_NAME", "Jupiterp"), + SiteBaseURL: envOr("SITE_BASE_URL", "https://www.jupiterp.com"), + + AllowedOrigins: splitList(envOr("V1_ALLOWED_ORIGINS", "https://www.jupiterp.com,https://jupiterp.com")), + AllowedEmailDomains: splitList(envOr("REVIEW_EMAIL_DOMAINS", "terpmail.umd.edu,umd.edu")), + + TriageWebhookURL: os.Getenv("REVIEW_TRIAGE_WEBHOOK_URL"), + TriageWebhookSecret: os.Getenv("REVIEW_TRIAGE_WEBHOOK_SECRET"), + TriageCallbackKey: os.Getenv("REVIEW_TRIAGE_CALLBACK_KEY"), + TriageTimeout: envDuration("REVIEW_TRIAGE_TIMEOUT_SEC", 108000*time.Second), + TriageRetryMax: envDuration("REVIEW_TRIAGE_RETRY_MAX_SEC", 90000*time.Second), + TriageMaxAttempts: envInt("REVIEW_TRIAGE_MAX_ATTEMPTS", 3), + TriageDisabled: envBool("REVIEW_TRIAGE_DISABLED", false), + DiscordWebhookURL: os.Getenv("DISCORD_MODERATION_WEBHOOK_URL"), + + AutoReject: envBool("REVIEW_TRIAGE_AUTO_REJECT", false), + AutoApprove: envBool("REVIEW_TRIAGE_AUTO_APPROVE", false), + // Asymmetric on purpose. A wrongly rejected review annoys one student + // who can appeal or resubmit; a wrongly approved defamatory review is + // the case that causes real harm to someone who never opted in. So the + // bar to publish is higher than the bar to refuse. + AutoApproveMinConf: envFloat("REVIEW_TRIAGE_AUTO_APPROVE_MIN_CONFIDENCE", 0.90), + AutoRejectMinConf: envFloat("REVIEW_TRIAGE_AUTO_REJECT_MIN_CONFIDENCE", 0.85), + + PrefilterAutoReject: envBool("REVIEW_TRIAGE_PREFILTER_AUTO_REJECT", false), + } + return c +} + +// WriteEnabled reports whether the /v1 group can be served at all. +// +// The service key is the gate: without it there is no write path, and mounting +// routes that cannot work only produces confusing 500s. +func (c *Config) WriteEnabled() bool { + return c.ServiceKey != "" && c.EmailPepper != "" +} + +// Validate fails fast on configurations that are wrong in ways that would +// otherwise be silent. +func (c *Config) Validate() { + if !c.WriteEnabled() { + log.Printf("v1 write path DISABLED: DATABASE_SERVICE_KEY and REVIEW_EMAIL_PEPPER are both required") + return + } + + var fatal []string + + if c.AdminKey == "" { + fatal = append(fatal, "REVIEW_ADMIN_KEY is required when the write path is enabled; "+ + "without it the moderation queue is unauthenticated") + } + if len(c.AdminKey) > 0 && len(c.AdminKey) < 32 { + fatal = append(fatal, "REVIEW_ADMIN_KEY is shorter than 32 characters; it is the only "+ + "thing standing in front of the moderation surface") + } + for name, key := range c.ModeratorKeys { + if len(key) < 32 { + fatal = append(fatal, "moderator key for "+name+" is shorter than 32 characters") + } + if key == c.AdminKey { + fatal = append(fatal, "moderator key for "+name+" duplicates REVIEW_ADMIN_KEY, "+ + "so its decisions would be indistinguishable from the shared key's") + } + if key == c.TriageCallbackKey { + fatal = append(fatal, "moderator key for "+name+" duplicates REVIEW_TRIAGE_CALLBACK_KEY") + } + } + + // The single most important ordering constraint in the triage design. + // + // The sweeper escalates anything pending longer than TriageTimeout. The + // retry parks quota-blocked reviews until the daily quota resets. If the + // timeout is shorter than the parking window, every quota-blocked review + // is escalated to a human before its retry ever fires, and the retry queue + // is dead code that looks like it works. + if c.TriageTimeout <= c.TriageRetryMax { + fatal = append(fatal, "REVIEW_TRIAGE_TIMEOUT_SEC must exceed REVIEW_TRIAGE_RETRY_MAX_SEC "+ + "(got "+c.TriageTimeout.String()+" vs "+c.TriageRetryMax.String()+"); otherwise the "+ + "sweeper escalates every quota-blocked review before its retry runs") + } + + if c.TriageWebhookURL != "" && c.TriageWebhookSecret == "" { + fatal = append(fatal, "REVIEW_TRIAGE_WEBHOOK_SECRET is required when "+ + "REVIEW_TRIAGE_WEBHOOK_URL is set; the webhook endpoint is on the public "+ + "internet and the signature is what stops it being fed fabricated reviews") + } + if c.TriageCallbackKey != "" && len(c.TriageCallbackKey) < 32 { + fatal = append(fatal, "REVIEW_TRIAGE_CALLBACK_KEY is shorter than 32 characters") + } + if c.TriageCallbackKey != "" && c.TriageCallbackKey == c.AdminKey { + fatal = append(fatal, "REVIEW_TRIAGE_CALLBACK_KEY must not equal REVIEW_ADMIN_KEY; "+ + "the point of a scoped key is that a compromised n8n cannot reach the "+ + "rest of the admin surface") + } + + if (c.AutoApprove || c.AutoReject) && c.TriageWebhookURL == "" { + fatal = append(fatal, "auto-approve or auto-reject is enabled but "+ + "REVIEW_TRIAGE_WEBHOOK_URL is empty, so nothing will ever produce a decision") + } + + for _, msg := range fatal { + log.Printf("CONFIG ERROR: %s", msg) + } + if len(fatal) > 0 { + log.Fatalf("refusing to start with %d configuration error(s)", len(fatal)) + } + + // Loud warnings for states that are valid but easy to be in by accident. + if c.TurnstileKey == "" { + log.Printf("WARNING: TURNSTILE_SECRET_KEY is empty; captcha verification is disabled") + } + if c.BrevoAPIKey == "" { + log.Printf("WARNING: BREVO_API_KEY is empty; verification email will be queued but never sent") + } + if c.TriageWebhookURL == "" { + log.Printf("Automated triage is off; every verified review goes to the human queue") + } + if c.PrefilterAutoReject { + log.Printf("Pre-filter auto-reject is ENABLED: reviews containing links, email " + + "addresses or phone numbers are refused without a human decision") + } else { + log.Printf("Pre-filter auto-reject is off; pre-filter hits are escalated to the human queue") + } + if c.AutoApprove { + log.Printf("Auto-approve is ENABLED above confidence %.2f with zero flags", c.AutoApproveMinConf) + } +} + +// parseModeratorKeys reads "alice:key1,bob:key2" into a name-to-key map. +// +// Malformed entries are dropped with a warning rather than failing the boot: +// a typo in one moderator's entry should not take the service down for +// everyone. An entry that is dropped simply cannot sign in, which is visible +// immediately to the person it belongs to. +func parseModeratorKeys(raw string) map[string]string { + keys := map[string]string{} + for _, entry := range strings.Split(raw, ",") { + entry = strings.TrimSpace(entry) + if entry == "" { + continue + } + name, key, found := strings.Cut(entry, ":") + name = strings.TrimSpace(name) + key = strings.TrimSpace(key) + if !found || name == "" || key == "" { + log.Printf("WARNING: ignoring malformed REVIEW_MODERATOR_KEYS entry %q; expected name:key", entry) + continue + } + keys[name] = key + } + return keys +} + +func mustEnv(key string) string { + val := os.Getenv(key) + if val == "" { + log.Fatalf("missing required env var: %s", key) + } + return val +} + +func envOr(key, fallback string) string { + if v := os.Getenv(key); v != "" { + return v + } + return fallback +} + +func envInt(key string, fallback int) int { + if v := os.Getenv(key); v != "" { + if n, err := strconv.Atoi(v); err == nil { + return n + } + log.Printf("WARNING: %s is not an integer; using %d", key, fallback) + } + return fallback +} + +func envFloat(key string, fallback float64) float64 { + if v := os.Getenv(key); v != "" { + if f, err := strconv.ParseFloat(v, 64); err == nil { + return f + } + log.Printf("WARNING: %s is not a number; using %v", key, fallback) + } + return fallback +} + +func envBool(key string, fallback bool) bool { + if v := os.Getenv(key); v != "" { + if b, err := strconv.ParseBool(v); err == nil { + return b + } + log.Printf("WARNING: %s is not a boolean; using %v", key, fallback) + } + return fallback +} + +func envDuration(key string, fallback time.Duration) time.Duration { + if v := os.Getenv(key); v != "" { + if n, err := strconv.Atoi(v); err == nil { + return time.Duration(n) * time.Second + } + log.Printf("WARNING: %s is not an integer number of seconds; using %s", key, fallback) + } + return fallback +} + +func splitList(raw string) []string { + parts := strings.Split(raw, ",") + out := make([]string, 0, len(parts)) + for _, p := range parts { + if trimmed := strings.TrimSpace(p); trimmed != "" { + out = append(out, strings.ToLower(trimmed)) + } + } + return out +} diff --git a/docs.html b/docs.html index d1f1c8c..c7f4e17 100644 --- a/docs.html +++ b/docs.html @@ -2,15 +2,26 @@
+Welcome to the Jupiterp API, a free and open-source API to get detailed course data for the University of Maryland. Currently, the API is in pre-release phase and is unstable; expect breaking changes, but the information in these docs should be correct and up-to-date.
For any questions or bugs, please contact admin@jupiterp.com.
Feel free to view or contribute to the project on GitHub.
+Everything is served under /v1. Use it for new work.
/v0 still works and is not going away. Every read endpoint documented here
+ answers on both prefixes, from the same handlers, returning the same bytes —
+ /v0/courses and /v1/courses are the same endpoint. Existing clients,
+ including @jupiterp/jupiterp
+ 1.x, need no change and there is no migration deadline.
The two prefixes are held together by a parity check that compares every read + endpoint across both, so they cannot quietly drift apart.
/v0/ |
+ /v1/ |
Base endpoint | -jump | +jump |
/v0/courses |
+ /v1/courses |
Get a list of courses with full course info | -jump | +jump |
/v0/courses/minified |
+ /v1/courses/minified |
Get a list of courses with just the code and title for each | -jump | +jump |
/v0/courses/withSections |
+ /v1/courses/withSections |
Get a list of courses, including section data for each course | -jump | +jump |
/v0/sections |
+ /v1/sections |
Get a list of sections for courses | -jump | +jump |
/v0/instructors |
+ /v1/instructors |
Get a list of instructors and their ratings | -jump | +jump |
/v0/instructors/active |
+ /v1/instructors/active |
Get a list of instructors actively teaching a course | -jump | +jump |
/v0/deptList |
+ /v1/deptList |
Get a list of 4-letter department codes | -jump | +jump | +
/v1/grades |
+ Get grade distributions for individual sections | +jump | +||
/v1/grades/summary |
+ Get grade distributions aggregated by course, term, or instructor | +jump | +||
/v1/grades/terms |
+ Get the terms for which grade data is available | +jump |
/v0//v1/This is the base endpoint for v0 of the Jupiterp API. It will simply return a HTTP StatusOK with some text to indicate that the Jupiterp API is online.
-/v0/coursesThis is the base endpoint for the Jupiterp API. It will simply return a HTTP StatusOK with some text to indicate that the Jupiterp API is online.
+/v1/coursesGets a list of courses that match the given query parameters. This endpoint does not return section information; for section info, use the sections endpoint listed below.
Request: GET http://api.jupiterp.com/v0/courses?courseCodes=CMSC131,MATH141
Request: GET http://api.jupiterp.com/v1/courses?courseCodes=CMSC131,MATH141
Response:
-[
- {
- "course_code": "CMSC131",
- "name": "Object-Oriented Programming I",
- "min_credits": 4,
- "max_credits": null,
- "gen_eds": null,
- "conditions": [
- "Corequisite: MATH140. ",
- "Credit only granted for: CMSC131, CMSC133 or CMSC141."
- ],
- "description": "Introduction to programming and computer science.
- Emphasizes understanding and implementation of applications using
- object-oriented techniques. Develops skills such as program design and
- testing as well as implementation of programs using a graphical IDE.
- Programming done in Java."
- },
- {
- "course_code": "MATH141",
- "name": "Calculus II",
- "min_credits": 4,
- "max_credits": null,
- "gen_eds": null,
- "conditions": [
- "Prerequisite: Minimum grade of C- in MATH140."
- ],
- "description": "Continuation of MATH140, including techniques of
- integration, improper integrals, applications of integration (such as
- volumes, work, arc length, moments), inverse functions, exponential and
- logarithmic functions, sequences and series."
- }
- ]
- Request: GET http://api.jupiterp.com/v0/courses?genEds=DVUP,DSSP&limit=2&sortBy=courseCode.asc
[
+ {
+ "course_code": "CMSC131",
+ "name": "Object-Oriented Programming I",
+ "min_credits": 4,
+ "max_credits": null,
+ "gen_eds": null,
+ "conditions": [
+ "Corequisite: MATH140. ",
+ "Credit only granted for: CMSC131, CMSC133 or CMSC141."
+ ],
+ "description": "Introduction to programming and computer science.
+ Emphasizes understanding and implementation of applications using
+ object-oriented techniques. Develops skills such as program design and
+ testing as well as implementation of programs using a graphical IDE.
+ Programming done in Java."
+ },
+ {
+ "course_code": "MATH141",
+ "name": "Calculus II",
+ "min_credits": 4,
+ "max_credits": null,
+ "gen_eds": null,
+ "conditions": [
+ "Prerequisite: Minimum grade of C- in MATH140."
+ ],
+ "description": "Continuation of MATH140, including techniques of
+ integration, improper integrals, applications of integration (such as
+ volumes, work, arc length, moments), inverse functions, exponential and
+ logarithmic functions, sequences and series."
+ }
+]
+
+ Request: GET http://api.jupiterp.com/v1/courses?genEds=DVUP,DSSP&limit=2&sortBy=courseCode.asc
Response:
-[
- {
- "course_code": "AAST351",
- "name": "Asian Americans and Media",
- "min_credits": 3,
- "max_credits": null,
- "gen_eds": [
- "DSSP",
- "DVUP"
- ],
- "conditions": [
- "Credit only granted for: AAST351, AAST398M or AAST398N. ",
- "Formerly: AAST398M, AAST398N."
- ],
- "description": "From yellow peril invaders to model minority allies, Asian
- Americans have crafted their own dynamic cultural expressions in a number
- of media from film, television, and music to fashion, sports, and food that
- reveal and contest the contradictions of the U.S. nation-state. Asian
- American culture also uniquely sits at the nexus of immigration flows and
- digital technologies, providing a transnational lens to view the US place
- in the world. This advanced course, then, will introduce students to the
- study and practice of Asian American culture as multiple , hybrid, and
- heterogeneous. It will do so through three sections: section one will
- introduce students to classical, cultural, and media concepts as well as
- relevant keywords outlined by Asian American Studies scholars; section two
- will review the work of Asian American cultural theorists; section three
- will focus on analyses of particular Asian American cultural productions.
- In doing so, students will gain an understanding of the shifting and
- interlocking tensions among the local, the national, and the global that
- form the cultural geographies of Asian America."
- },
- {
- "course_code": "AMST320",
- "name": "(Dis)ability in American Film",
- "min_credits": 3,
- "max_credits": null,
- "gen_eds": [
- "DSHU",
- "DSSP",
- "DVUP"
- ],
- "conditions": [
- "Credit only granted for: AMST320 or AMST328X. ",
- "Formerly: AMST328X."
- ],
- "description": "Explores the connection between film and disability
- through an analysis of independent and mainstream American films in various
- film genres. Specifically, we will consider how these film representations
- reflect and/or challenge the shifting social perspectives of disability
- over the 20th and 21st centuries. Beginning with the presentation of
- disability as theatrical spectacle in the traveling sideshow and early
- cinema, we will work our way through film history to develop an
- understanding of our society's complicated relationship with disability."
- }
- ]
- /v0/courses/minified[
+ {
+ "course_code": "AAST351",
+ "name": "Asian Americans and Media",
+ "min_credits": 3,
+ "max_credits": null,
+ "gen_eds": [
+ "DSSP",
+ "DVUP"
+ ],
+ "conditions": [
+ "Credit only granted for: AAST351, AAST398M or AAST398N. ",
+ "Formerly: AAST398M, AAST398N."
+ ],
+ "description": "From yellow peril invaders to model minority allies, Asian
+ Americans have crafted their own dynamic cultural expressions in a number
+ of media from film, television, and music to fashion, sports, and food that
+ reveal and contest the contradictions of the U.S. nation-state. Asian
+ American culture also uniquely sits at the nexus of immigration flows and
+ digital technologies, providing a transnational lens to view the US place
+ in the world. This advanced course, then, will introduce students to the
+ study and practice of Asian American culture as multiple , hybrid, and
+ heterogeneous. It will do so through three sections: section one will
+ introduce students to classical, cultural, and media concepts as well as
+ relevant keywords outlined by Asian American Studies scholars; section two
+ will review the work of Asian American cultural theorists; section three
+ will focus on analyses of particular Asian American cultural productions.
+ In doing so, students will gain an understanding of the shifting and
+ interlocking tensions among the local, the national, and the global that
+ form the cultural geographies of Asian America."
+ },
+ {
+ "course_code": "AMST320",
+ "name": "(Dis)ability in American Film",
+ "min_credits": 3,
+ "max_credits": null,
+ "gen_eds": [
+ "DSHU",
+ "DSSP",
+ "DVUP"
+ ],
+ "conditions": [
+ "Credit only granted for: AMST320 or AMST328X. ",
+ "Formerly: AMST328X."
+ ],
+ "description": "Explores the connection between film and disability
+ through an analysis of independent and mainstream American films in various
+ film genres. Specifically, we will consider how these film representations
+ reflect and/or challenge the shifting social perspectives of disability
+ over the 20th and 21st centuries. Beginning with the presentation of
+ disability as theatrical spectacle in the traveling sideshow and early
+ cinema, we will work our way through film history to develop an
+ understanding of our society's complicated relationship with disability."
+ }
+]
+
+ /v1/courses/minifiedGets a minified list of courses that satisfy the given parameters. Takes the same parameters as the /v0/courses endpoint, but returns only the course code and title.
Same as the parameters for /v0/courses; see here.
Gets a minified list of courses that satisfy the given parameters. Takes the same parameters as the /v1/courses endpoint, but returns only the course code and title.
Same as the parameters for /v1/courses; see here.
Request: GET http://api.jupiterp.com/v0/courses/minified?prefix=ASTR4&sortBy=name.asc
Request: GET http://api.jupiterp.com/v1/courses/minified?prefix=ASTR4&sortBy=name.asc
Response:
-[
- {
- "course_code": "ASTR422",
- "name": "Cosmology"
- },
- {
- "course_code": "ASTR421",
- "name": "Galaxies"
- },
- {
- "course_code": "ASTR498",
- "name": "Special Problems in Astronomy"
- }
- ]
- /v0/courses/withSections[
+ {
+ "course_code": "ASTR422",
+ "name": "Cosmology"
+ },
+ {
+ "course_code": "ASTR421",
+ "name": "Galaxies"
+ },
+ {
+ "course_code": "ASTR498",
+ "name": "Special Problems in Astronomy"
+ }
+]
+
+ /v1/courses/withSectionsGets a list of full courses data and associated sections data. Each returned course also contains a (potentially-empty) list of sections for that course.
-sections |
Section[] | -A list of Sections. A Section consists of the fields described in the output of /v0/sections (see here) |
+ A list of Sections. A Section consists of the fields described in the output of /v1/sections (see here) |
Request: GET http://api.jupiterp.com/v0/courses/withSections?courseCodes=CMSC433
Request: GET http://api.jupiterp.com/v1/courses/withSections?courseCodes=CMSC433
Response:
-[
- {
- "course_code": "CMSC433",
- "name": "Programming Language Technologies and Paradigms",
- "min_credits": 3,
- "max_credits": null,
- "gen_eds": null,
- "conditions": [
- "Prerequisite: Minimum grade of C- in CMSC330; or must be in the
- (Computer Science (Doctoral), Computer Science (Master's)) program. ",
- "Restriction: Permission of CMNS-Computer Science department."
- ],
- "description": "Programming language technologies (e.g., object-oriented
- programming), their implementations and use in software design and
- implementation.",
- "sections": [
- {
- "holdfile": 0,
- "meetings": [
- "TuTh-11:00am-12:15pm-CSI-1115"
- ],
- "sec_code": "0101",
- "waitlist": 3,
- "open_seats": 0,
- "course_code": "CMSC433",
- "instructors": [
- "Anwar Mamat"
- ],
- "total_seats": 140
- },
- {
- "holdfile": null,
- "meetings": [
- "TuTh-3:30pm-4:45pm-IRB-0318"
- ],
- "sec_code": "0201",
- "waitlist": 0,
- "open_seats": 7,
- "course_code": "CMSC433",
- "instructors": [
- "Anwar Mamat"
- ],
- "total_seats": 50
- }
- ]
- }
- ]
- /v0/sections[
+ {
+ "course_code": "CMSC433",
+ "name": "Programming Language Technologies and Paradigms",
+ "min_credits": 3,
+ "max_credits": null,
+ "gen_eds": null,
+ "conditions": [
+ "Prerequisite: Minimum grade of C- in CMSC330; or must be in the
+ (Computer Science (Doctoral), Computer Science (Master's)) program. ",
+ "Restriction: Permission of CMNS-Computer Science department."
+ ],
+ "description": "Programming language technologies (e.g., object-oriented
+ programming), their implementations and use in software design and
+ implementation.",
+ "sections": [
+ {
+ "holdfile": 0,
+ "meetings": [
+ "TuTh-11:00am-12:15pm-CSI-1115"
+ ],
+ "sec_code": "0101",
+ "waitlist": 3,
+ "open_seats": 0,
+ "course_code": "CMSC433",
+ "instructors": [
+ "Anwar Mamat"
+ ],
+ "total_seats": 140
+ },
+ {
+ "holdfile": null,
+ "meetings": [
+ "TuTh-3:30pm-4:45pm-IRB-0318"
+ ],
+ "sec_code": "0201",
+ "waitlist": 0,
+ "open_seats": 7,
+ "course_code": "CMSC433",
+ "instructors": [
+ "Anwar Mamat"
+ ],
+ "total_seats": 50
+ }
+ ]
+ }
+]
+
+ /v1/sectionsGet sections for specific courses, or for all courses that match a course code prefix. Note that some courses don't have any sections; for example, most independent research courses, like ASTR498, will not return any sections.
-Get sections for specific courses, or for all courses that match a course code prefix. Note that some courses don't have any sections; for example, most independent research courses, like ASTR498, will not return any sections.
+Request: GET http://api.jupiterp.com/v0/sections?courseCodes=CMSC433
Request: GET http://api.jupiterp.com/v1/sections?courseCodes=CMSC433
Response:
-[
- {
- "course_code": "CMSC433",
- "sec_code": "0101",
- "instructors": [
- "Anwar Mamat"
- ],
- "meetings": [
- "TuTh-11:00am-12:15pm-CSI-1115"
- ],
- "open_seats": 0,
- "total_seats": 140,
- "waitlist": 7,
- "holdfile": 0
- },
- {
- "course_code": "CMSC433",
- "sec_code": "0201",
- "instructors": [
- "Anwar Mamat"
- ],
- "meetings": [
- "TuTh-3:30pm-4:45pm-IRB-0318"
- ],
- "open_seats": 15,
- "total_seats": 50,
- "waitlist": 0,
- "holdfile": null
- }
- ]
- /v0/instructors[
+ {
+ "course_code": "CMSC433",
+ "sec_code": "0101",
+ "instructors": [
+ "Anwar Mamat"
+ ],
+ "meetings": [
+ "TuTh-11:00am-12:15pm-CSI-1115"
+ ],
+ "open_seats": 0,
+ "total_seats": 140,
+ "waitlist": 7,
+ "holdfile": 0
+ },
+ {
+ "course_code": "CMSC433",
+ "sec_code": "0201",
+ "instructors": [
+ "Anwar Mamat"
+ ],
+ "meetings": [
+ "TuTh-3:30pm-4:45pm-IRB-0318"
+ ],
+ "open_seats": 15,
+ "total_seats": 50,
+ "waitlist": 0,
+ "holdfile": null
+ }
+]
+
+ /v1/instructorsGet a list of all instructors and their average ratings, including instructors not actively teaching any courses.
-instructorSlugs (optional) |
- A comma-separated list of instructor slugs to get results for; slugs are the internal identifier used to distinguish an instructor and are unique to each instructor. See PlanetTerp API spec for more info. Cannot set both instructorNames and instructorSlugs. |
- instructorSlugs=testudo,pines |
+ A comma-separated list of instructor slugs to get results for; slugs are the internal identifier used to distinguish an instructor and are unique to each instructor. Cannot set both instructorNames and instructorSlugs. |
+ instructorSlugs=shane-walsh,darryll-pines |
+
nameSearch (optional) |
+ Case-insensitive substring match on instructor name. Matched against a normalized form of the name, so accents and punctuation are ignored on both sides: obrien matches "O'Brien" and jose matches "José". |
+ nameSearch=walsh |
+ ||
activeOnly (optional) |
+ If true, only returns instructors currently teaching at least one section. | +activeOnly=true |
+ ||
count (optional) |
+ If true, the total number of matching records is returned in the Content-Range response header (0-49/4812). Costs an extra aggregate over the filtered set, so it is off by default. |
+ count=true |
||
ratings (optional) |
@@ -666,7 +712,7 @@
name |
string | -The instructor's name as listed on PlanetTerp | +The instructor's name as listed on PlanetTerp |
average_rating |
@@ -693,81 +739,83 @@
Request: GET http://api.jupiterp.com/v0/instructors?ratings=gt.4.5&ratings=lt.5&limit=5&sortBy=average_rating.desc,name.desc
Request: GET http://api.jupiterp.com/v1/instructors?ratings=gt.4.5&ratings=lt.5&limit=5&sortBy=average_rating.desc,name.desc
Response:
-[
- {
- "slug": "gramlich_meredith",
- "name": "Meredith Gramlich",
- "average_rating": 4.9667
- },
- {
- "slug": "cropper",
- "name": "Maureen Cropper",
- "average_rating": 4.9474
- },
- {
- "slug": "gruber_sean",
- "name": "Sean Gruber",
- "average_rating": 4.9398
- },
- {
- "slug": "o’brien",
- "name": "Terrence O’Brien",
- "average_rating": 4.9375
- },
- {
- "slug": "zomback",
- "name": "Jenna Zomback",
- "average_rating": 4.9355
- }
- ]
- /v0/instructors/active[
+ {
+ "slug": "gramlich_meredith",
+ "name": "Meredith Gramlich",
+ "average_rating": 4.9667
+ },
+ {
+ "slug": "cropper",
+ "name": "Maureen Cropper",
+ "average_rating": 4.9474
+ },
+ {
+ "slug": "gruber_sean",
+ "name": "Sean Gruber",
+ "average_rating": 4.9398
+ },
+ {
+ "slug": "o’brien",
+ "name": "Terrence O’Brien",
+ "average_rating": 4.9375
+ },
+ {
+ "slug": "zomback",
+ "name": "Jenna Zomback",
+ "average_rating": 4.9355
+ }
+]
+
+ /v1/instructors/activeGet all instructors that are currently teaching a course, as listed on Testudo.
-Same as /v0/instructors; see here.
Same as /v0/instructors; see here.
Same as /v1/instructors; see here.
Same as /v1/instructors; see here.
Request: GET http://api.jupiterp.com/v0/instructors/active?limit=5
Request: GET http://api.jupiterp.com/v1/instructors/active?limit=5
Response:
-[
- {
- "slug": "abadi_daniel",
- "name": "Daniel Abadi",
- "average_rating": 3.122
- },
- {
- "slug": "abasi",
- "name": "Ali Abasi",
- "average_rating": null
- },
- {
- "slug": "abbasi",
- "name": "Hossein Abbasi",
- "average_rating": 3.7791
- },
- {
- "slug": "abdul-alim",
- "name": "Jamaal Abdul-Alim",
- "average_rating": 3.25
- },
- {
- "slug": "abioye",
- "name": "Victor Abioye",
- "average_rating": null
- }
- ]
- /v0/deptList[
+ {
+ "slug": "abadi_daniel",
+ "name": "Daniel Abadi",
+ "average_rating": 3.122
+ },
+ {
+ "slug": "abasi",
+ "name": "Ali Abasi",
+ "average_rating": null
+ },
+ {
+ "slug": "abbasi",
+ "name": "Hossein Abbasi",
+ "average_rating": 3.7791
+ },
+ {
+ "slug": "abdul-alim",
+ "name": "Jamaal Abdul-Alim",
+ "average_rating": 3.25
+ },
+ {
+ "slug": "abioye",
+ "name": "Victor Abioye",
+ "average_rating": null
+ }
+]
+
+ /v1/deptListGet a list of 4-letter department codes.
-None
-/v1/gradesGets grade distributions for individual course sections, as released by the University Registrar under the Maryland Public Information Act. Data covers fall and spring terms from Fall 2010 through Spring 2026; winter and summer terms were not released.
+For counts aggregated across sections, terms, or instructors, use the summary endpoint below.
| param | +description | +example | +
|---|---|---|
courseCodes (optional) |
+ A string of one or multiple comma-separated course codes; cannot be combined with prefix or number. |
+ courseCodes=CMSC132,MATH141 |
+
prefix (optional) |
+ The course prefix to match records to; for instance, CMSC1 would match all CMSC1XX courses. |
+ prefix=CMSC1 |
+
number (optional) |
+ The course number to search for across multiple departments. | +number=433 |
+
term (optional) |
+ A string of equalities/inequalities to filter by term code. Possible expressions are: eq, lte, lt, gt, gte, neq, and in for a specific set. For multiple conditions, use multiple term arguments. |
+ term=gte.202008 or term=in.(202408,202501) |
+
instructor (optional) |
+ Return only sections taught by the given instructor, written in "First Last" order. This field is case-sensitive. | +instructor=Larry%20Herman |
+
instructorSource (optional) |
+ A comma-separated list of instructor_source values to include. Defaults to all. Use reported,lead to exclude attributions carried across lecture groups. |
+ instructorSource=reported |
+
gpa (optional) |
+ A string of equalities/inequalities to filter by computed GPA. | +gpa=gte.3.5 |
+
graded (optional) |
+ A string of equalities/inequalities to filter by how many students received a letter grade. Useful for excluding sections too small to draw conclusions from. | +graded=gte.30 |
+
limit (optional) |
+ Maximum number of records to return; defaults to 100, maximum of 500. | +limit=10 |
+
offset (optional) |
+ How many records to skip when returning results; defaults to 0. | +offset=10 |
+
sortBy (optional) |
+ A comma-separated list of which columns to sort by, ascending (.asc) or descending (.desc). |
+ sortBy=term.desc,sec_code.asc |
+
| field | +type | +description | +
|---|---|---|
term |
+ int | +Six-digit term code: the four-digit year followed by the month the term begins (01 spring, 08 fall). Fall 2024 is 202408. |
+
course_code |
+ string | +The course code, matching course_code elsewhere in this API. A course that has since been retired will have grade records but no entry in /v1/courses. |
+
sec_code |
+ string | +The section code, matching sec_code on /v1/sections. |
+
instructor |
+ string or null | +The instructor exactly as the Registrar printed them, in "Last, First Middle" order. Null where the release left the field blank. | +
instructor_name |
+ string or null | +The effective instructor in "First Last" order, suitable for matching against /v1/instructors. May be populated where instructor is null; see instructor_source. |
+
instructor_source |
+ string or null | +How instructor_name was determined. reported means the Registrar named them on this row. lead means the name was carried from the lead section of the same lecture, which is how the release records discussion and lab sections. course means it was carried from a different lecture group or a differently-coded offering, and is materially less reliable. Null where no section of the course was named. |
+
total |
+ int | +Students enrolled, as reported. From Fall 2017 this equals the sum of the fifteen grade buckets; in earlier terms it can exceed that sum by a few students whose outcome the older report did not categorize. Prefer graded as a denominator when comparing across that boundary. |
+
a_plus, a, a_minus … d_minus, f |
+ int | +Students receiving each letter grade. | +
w |
+ int | +Students who withdrew. | +
other |
+ int | +Students receiving a non-letter outcome (pass/fail, incomplete, audit, and similar). | +
graded |
+ int | +Students who received a letter grade; the denominator used for gpa. |
+
gpa |
+ number or null | +Mean GPA on the UMD 4.0 scale over graded students. Withdrawals and non-letter outcomes are excluded from both the numerator and the denominator. Null where nobody received a letter grade. |
+
Request: GET http://api.jupiterp.com/v1/grades?courseCodes=CMSC132&term=eq.202408&limit=2
Response:
+[
+ {
+ "term": 202408,
+ "course_code": "CMSC132",
+ "sec_code": "0101",
+ "instructor": "Herman, Larry",
+ "instructor_name": "Larry Herman",
+ "instructor_source": "reported",
+ "total": 32,
+ "a_plus": 0,
+ "a": 1,
+ "a_minus": 4,
+ "graded": 30,
+ "gpa": 2.583
+ },
+ {
+ "term": 202408,
+ "course_code": "CMSC132",
+ "sec_code": "0102",
+ "instructor": null,
+ "instructor_name": "Larry Herman",
+ "instructor_source": "lead",
+ "total": 34,
+ "a_plus": 2,
+ "a": 7,
+ "a_minus": 5,
+ "graded": 34,
+ "gpa": 3.118
+ }
+]
+
+ Note the second record: the release lists the instructor once against the lecture and leaves the discussion sections blank, so instructor is null while instructor_name carries the lecturer's name and instructor_source records that it was inferred.
/v1/grades/summaryGets grade distributions with the individual sections summed together. This is usually the endpoint you want: groupBy=course answers "how hard is this course", groupBy=term answers "has it changed", groupBy=instructor answers "who should I take it with", and groupBy=instructorOverall answers "how does this professor grade in general".
Note that instructorOverall and instructorTerm aggregate across every course, so they take no course filter; passing courseCodes, prefix, or number with them returns 400 rather than silently ignoring the filter.
| param | +description | +example | +
|---|---|---|
groupBy (optional) |
+ One of course (default), term, instructor, instructorOverall, or instructorTerm. course returns one record per course across every term on file; term one record per course per term; instructor one record per course per instructor; instructorOverall one record per instructor across every course they have taught; instructorTerm one record per instructor per term. |
+ groupBy=instructorOverall |
+
includeCarried (optional) |
+ Only meaningful with groupBy=instructor. When true, also counts sections whose instructor was carried across lecture groups (instructor_source of course). Wider coverage, lower confidence. Defaults to false. |
+ includeCarried=true |
+
courseCodes (optional) |
+ A string of one or multiple comma-separated course codes; cannot be combined with prefix or number. |
+ courseCodes=CMSC132 |
+
prefix (optional) |
+ The course prefix to match records to. | +prefix=CMSC3 |
+
number (optional) |
+ The course number to search for across multiple departments. | +number=433 |
+
term (optional) |
+ Equalities/inequalities to filter by term code. Only valid with groupBy=term or groupBy=instructorTerm; the other groupings aggregate across every term on file and will reject this parameter rather than ignore it. |
+ term=gte.202008 |
+
instructor (optional) |
+ Return only the given instructor, in "First Last" order. Case-sensitive, exact. Prefer instructorSlug: the same professor is spelled several different ways across the registrar's grade files, Testudo, and PlanetTerp, so an exact name match silently returns nothing for a large share of instructors. Requires an instructor grouping. |
+ instructor=Anwar%20Mamat |
+
instructorSlug (optional) |
+ Return only the given instructor, by Jupiterp slug. This resolves through instructor identity rather than string equality, so it cannot miss because of a middle name or an accent. Requires an instructor grouping. | +instructorSlug=shane-walsh |
+
instructorId (optional) |
+ Return only the given instructor, by numeric id. Requires an instructor grouping. | +instructorId=4711 |
+
gpa (optional) |
+ Equalities/inequalities to filter by the aggregated GPA. | +gpa=gte.3.0 |
+
minStudents (optional) |
+ Exclude groups with fewer than this many students who received a letter grade. Applied to graded, not total: before Fall 2017 the registrar's total includes students whose outcome was never categorized, so it is not comparable across eras, while graded is also the GPA denominator. |
+ minStudents=100 |
+
count (optional) |
+ If true, the total number of matching records is returned in the Content-Range response header. |
+ count=true |
+
limit (optional) |
+ Maximum number of records to return; defaults to 100, maximum of 500. | +limit=10 |
+
offset (optional) |
+ How many records to skip; defaults to 0. | +offset=10 |
+
sortBy (optional) |
+ A comma-separated list of which columns to sort by. | +sortBy=gpa.desc |
+
All groupings return the summed grade buckets (a_plus through other), total, graded, and gpa, defined exactly as on /v1/grades. In addition:
| field | +type | +description | +
|---|---|---|
course_code |
+ string | +The course these counts are for. | +
term |
+ int | +Only present when groupBy=term. |
+
instructor |
+ string | +Present on the instructor groupings; the instructor's canonical display name. | +
instructor_id |
+ int | +Present on the instructor groupings; the Jupiterp instructor id. | +
instructor_slug |
+ string | +Present on the instructor groupings; the Jupiterp slug, which is the professor page URL segment. | +
course_count |
+ int | +Only present when groupBy=instructorOverall or instructorTerm; how many distinct courses are represented. |
+
section_count |
+ int | +How many individual sections were summed. | +
term_count |
+ int | +How many distinct terms are represented. Not present when groupBy=term. |
+
first_term, last_term |
+ int | +The earliest and latest term represented. Not present when groupBy=term. |
+
Request: GET http://api.jupiterp.com/v1/grades/summary?courseCodes=CMSC351
Response:
+[
+ {
+ "course_code": "CMSC351",
+ "section_count": 97,
+ "term_count": 32,
+ "first_term": 201008,
+ "last_term": 202601,
+ "total": 14969,
+ "graded": 13346,
+ "a_plus": 450,
+ "a": 1578,
+ "a_minus": 1153,
+ "b_plus": 1313,
+ "b": 2169,
+ "b_minus": 1441,
+ "c_plus": 1263,
+ "c": 1583,
+ "c_minus": 1002,
+ "d_plus": 185,
+ "d": 846,
+ "d_minus": 70,
+ "f": 293,
+ "w": 738,
+ "other": 791,
+ "gpa": 2.699
+ }
+]
+
+ Request: GET http://api.jupiterp.com/v1/grades/summary?groupBy=instructor&courseCodes=CMSC330&minStudents=1000&sortBy=gpa.desc
Response:
+[
+ {
+ "course_code": "CMSC330",
+ "instructor": "Michael W. Hicks",
+ "section_count": 37,
+ "term_count": 7,
+ "first_term": 201301,
+ "last_term": 202101,
+ "total": 1205,
+ "graded": 1076,
+ "gpa": 3.123
+ },
+ {
+ "course_code": "CMSC330",
+ "instructor": "Roger D. Eastman",
+ "section_count": 39,
+ "term_count": 5,
+ "first_term": 201808,
+ "last_term": 202108,
+ "total": 1258,
+ "graded": 1045,
+ "gpa": 3.047
+ }
+]
+
+ /v1/grades/termsGets every term for which grade data has been loaded, newest first. Takes no parameters. Useful for discovering coverage before querying, since the released data covers fall and spring only.
+| field | +type | +description | +
|---|---|---|
term |
+ int | +Six-digit term code. | +
section_count |
+ int | +Sections with grade data in this term. | +
course_count |
+ int | +Distinct courses with grade data in this term. | +
total |
+ int | +Students enrolled across every section. | +
graded |
+ int | +Students who received a letter grade. | +
gpa |
+ number | +Mean GPA across the whole university for the term. | +
Request: GET http://api.jupiterp.com/v1/grades/terms
Response:
+[
+ {
+ "term": 202601,
+ "section_count": 6543,
+ "course_count": 3213,
+ "total": 168366,
+ "graded": 161397,
+ "gpa": 3.488
+ },
+ {
+ "term": 202508,
+ "section_count": 7056,
+ "course_count": 3263,
+ "total": 186608,
+ "graded": 176931,
+ "gpa": 3.503
+ }
+]
+
+ The endpoints below write, and they are governed differently from the read
+ endpoints above even though both are served under /v1. Writes need things
+ reads do not: an origin allowlist, authentication, rate limiting, and a captcha.
+ The read endpoints stay permissive, unauthenticated, and cacheable.
That difference is worth stating plainly, because it is the one thing the shared
+ prefix hides. Reads accept requests from any origin. Writes accept them only
+ from an allowlist (V1_ALLOWED_ORIGINS). A browser on an unrelated domain can
+ call GET /v1/courses and will be refused by POST /v1/reviews.
Reviews are pre-moderated. Nothing submitted here is publicly visible until + a moderator approves it, and that is true whether the decision is made by a + person or by the automated triage.
+| path | +method | +description | +
|---|---|---|
/v1/reviews |
+ GET | +Approved reviews for a professor | +
/v1/reviews |
+ POST | +Submit a review | +
/v1/reviews/verify/:token |
+ GET | +Confirm an emailed link | +
/v1/reviews/:id |
+ DELETE | +Withdraw (manage key) | +
/v1/reviews/:id/report |
+ POST | +Report a published review | +
/v1/admin/reviews |
+ GET | +Moderation queue (admin key) | +
/v1/admin/reviews/:id |
+ PUT | +Approve, reject, or escalate | +
/v1/admin/reports |
+ GET | +Open reports (admin key) | +
/v1/admin/sweep |
+ POST | +Scheduled maintenance (admin key). Answers 200 when every step succeeded and 207 with a failures object when any did not — alert on non-200. |
+
GET /v1/reviewsApproved reviews only, newest first. Served from a database view that cannot + express an unapproved row and does not contain the submitter's identity + columns at all.
+| parameter | +description | +example | +
|---|---|---|
instructorSlug (required) |
+ Whose reviews to return. | +instructorSlug=shane-walsh |
+
courseCode (optional) |
+ Restrict to one course. | +courseCode=CMSC132 |
+
limit, offset (optional) |
+ Paging; defaults 25 and 0. | +limit=50 |
+
The total is returned in the Content-Range header.
POST /v1/reviews{
+ "instructor_slug": "shane-walsh",
+ "course_code": "CMSC132",
+ "term": 202508,
+ "rating": 4.5,
+ "expected_grade": "A-",
+ "title": "Genuinely excellent lecturer",
+ "body": "…",
+ "email": "student@terpmail.umd.edu",
+ "captcha_token": "0.abc…"
+}
+
+ rating is a decimal between 1 and 5 on a half step — 4.5 is valid,
+ 4.3 is not. email must be a terpmail.umd.edu or umd.edu address; it is
+ stored only as a peppered hash, is never displayed, and is never shown to the
+ professor. course_code and term are optional, and term must be a Fall or
+ Spring term, because the grade dataset covers only those.
Responds 202 Accepted with {"status":"verification_sent"}.
The response is identical whether or not that address has already reviewed + this professor. A distinguishable "you have already reviewed this" would turn + the endpoint into an oracle for "did person X review professor Y", which is the + privacy property the hashing exists to provide.
+Rate limited to 5 per hour per IP, 3 per day per address, and 20 per hour per + professor across all submitters. The last one is what catches a coordinated + run on a single professor, which the per-person limits do nothing about.
+GET /v1/reviews/verify/:tokenConfirms the emailed link, moves the review to pending, and returns the
+ manage key once:
{ "status": "verified", "manage_key": "…", "message": "…" }
+
+ Idempotent: a second visit returns already_verified rather than an error,
+ because mail clients prefetch links and people double-click.
The manage key is also emailed. It cannot be recovered — there is deliberately + no way to link it back to a person.
+DELETE /v1/reviews/:idAuthorization: Bearer <manage key>.
A withdrawal is a soft delete — the row remains so the one-review-per-person + rule still holds, but the content is actually nulled.
+There is no edit endpoint. A published review is final text: the only way to + change what a review says is to withdraw it and write another. Editing after + approval is a way to get innocuous text past a moderator and then replace it, + and re-queueing every edit for moderation solves that at the cost of a flow + where a reviewer can silently republish. Withdrawal carries no such hole, so it + is the one the reviewer keeps.
+PUT /v1/admin/reviews/:id{
+ "action": "approve",
+ "reason": "…",
+ "confidence": 0.93,
+ "categories": [],
+ "policy_version": "2026-08-14",
+ "model": "gemini-2.0-flash-001"
+}
+
+ Two callers with different keys: a human moderator with the admin key, and the + automated triage with a narrowly scoped callback key that authorises this one + route. Which one acted is recorded on every decision.
+Idempotent — asking for the state a review is already in is a success, not a
+ second audit entry. State-guarded — only pending and escalated reviews are
+ decidable, and a late retry against a review a human already actioned returns
+ 409 rather than overturning it.
Every call writes an audit row. While shadow mode is on, an automated decision
+ is recorded with applied: false and the review is escalated to a human
+ instead.
| status | +meaning | +
|---|---|
400 |
+ Validation failed; the message names the field | +
401 |
+ Missing or wrong key | +
404 |
+ No such professor or review | +
409 |
+ Already decided by someone else | +
410 |
+ Verification link expired | +
429 |
+ Rate limited | +