# Admissions Essay Honor Code Worksheet — API Reference

A free API for institutions to get non-scoring context on an application
essay — academic snapshot, background context, plain writing-pattern
observations, and discussion prompts — to support an admissions officer's
own review. It never returns a score, risk level, or AI-authorship
verdict, and it never accepts, stores, or returns any student name or
student email.

Built for institutions processing large volumes of essays per cycle: a
single institution can hold several independent API keys (e.g. one per
office or per downstream system), and a bulk action lets you submit many
applications in one call instead of one request per essay.

## Two ways to run this

**Primary (recommended): `Code.gs` — a Google Apps Script Web App.**
No Python, no cPanel "Setup Python App," no server to keep running, no
hosting bill. Create a Google Sheet, paste one script, deploy, done —
covered in full below. This is what `worksheet-api-access.html` (the
public request/manage page) talks to.

**Alternative: `main.py` — a self-hosted FastAPI app.**
If you'd rather run your own Python server (more moving parts, but full
REST semantics with real HTTP status codes, and SQLite instead of a
Sheet), `main.py` + `passenger_wsgi.py` implement the identical feature
set for cPanel/Passenger deployment. See **Appendix: the FastAPI
alternative** at the bottom of this document. Everything else below
describes the Apps Script version.

---

## Setting up Code.gs (one time, ~2 minutes)

1. Create a new Google Sheet — this holds registered institutions and
   (optional) usage analytics. No columns to set up by hand; the script
   creates its own tabs and headers the first time it runs.
2. **Extensions → Apps Script.** Delete the placeholder code and paste in
   the entire contents of `Code.gs`.
3. **Deploy → New deployment → type "Web app".**
   - Execute as: **Me**
   - Who has access: **Anyone**
4. Click **Deploy**, authorize the permissions Google asks for, and copy
   the `/exec` URL it gives you. That URL is your entire API — there's
   nothing else to host.
5. Paste that URL into `WORKSHEET_API_BASE_URL` near the top of the
   `<script>` block in `worksheet-api-access.html`.

That's it — no `pip install`, no `requirements.txt`, no cPanel Python App,
no server process to keep alive. Google runs it for you, for free, inside
your own Google account.

## How the API is shaped

There is exactly **one URL** — your deployment's `/exec` link. Every
request is a `POST` with a JSON body that includes an `"action"` field
telling the script what to do:

```json
{ "action": "register", "...": "..." }
{ "action": "keys_list", "...": "..." }
{ "action": "keys_revoke", "...": "..." }
{ "action": "consent", "...": "..." }
{ "action": "worksheet", "...": "..." }
{ "action": "worksheet_bulk", "...": "..." }
```

### Important: no custom HTTP status codes

Apps Script Web Apps always respond with HTTP 200 — there is no way to
make them return a 401, 404, 422, etc. Every response body instead
carries:

- `"ok"`: `true` or `false`
- `"status_code"`: an **informational** HTTP-style number (200, 400, 401,
  403, 404, 422, 500) for your own branching logic — it does **not**
  reflect the actual HTTP status of the response, which is always 200.
- `"error"`: a message, present only when `"ok": false`.

Check `ok`, not the HTTP status, when integrating against this API.

### CORS note for browser-based calls

If you call this from a page in a browser (as `worksheet-api-access.html`
does), send the request body with `Content-Type: text/plain` instead of
`application/json`. Apps Script still parses it fine with `JSON.parse()`
on its end — sending it as `text/plain` is what keeps the browser's
request a CORS "simple request," so it never triggers a preflight
`OPTIONS` call, which Apps Script Web Apps cannot answer. This only
matters for browser calls; server-to-server calls (an institution's own
backend calling this API directly) aren't affected by CORS at all.

## No student data, ever

This API does not accept, store, or return any student-identifying
information — no student name, no student email, no student ID from your
system. If you need to match a result back to an applicant in your own
system, use the optional `applicant_ref` field on bulk items: send
whatever opaque token means something to *you* (an internal application
ID, a spreadsheet row number, a ticket number) — never an email address
or name. This API doesn't validate that `applicant_ref` is PII-free, so
that responsibility stays with the institution's own integration.

## Data collected, and why

| Field | Collected when | Stored how |
|---|---|---|
| College personnel email | Key registration | Hashed (SHA-256) immediately in the Institutions sheet; raw address is never persisted |
| Essay text | Every worksheet request | **Never stored.** Used in-memory to compute the response, then discarded |
| Academic/background fields | Every worksheet request | Only field *counts* (not values) are stored if consent = `accept`, for improvement analytics |
| `applicant_ref` (bulk only) | Bulk worksheet requests | **Never stored.** Echoed back in the response only, so you can match results to your own records |

> **Note on "no PII":** an email address is legally treated as personal
> data under FERPA/GDPR/CCPA even without a name attached. This API
> minimizes that exposure by hashing personnel emails before any storage
> and by never accepting or persisting any student email or name at all.

## Consent model

Consent can be set at two levels:

1. **Key default** — set at registration for that specific key, and
   updatable any time via `consent`.
2. **Per-request override** — the optional `data_sharing_consent` field on
   `worksheet` (or per-item on `worksheet_bulk`) overrides the key's
   default for that one call.

When consent is **`decline`**: the worksheet is generated and returned
normally; nothing about that request is stored anywhere.

When consent is **`accept`**: a row is added to the `UsageEvents` sheet
containing only: a random worksheet ID, the institution name, the
*derived* writing-pattern labels (e.g. "Low variation in sentence length"
— not the essay text itself), and counts of how many academic/background
fields were filled in. No email, name, or `applicant_ref` of any kind is
ever part of this record.

## Institutions and API keys

One institution can hold **multiple independent keys** — one per office,
department, or downstream system (e.g. "Undergraduate Admissions" vs.
"Graduate Admissions – Engineering"). Each key tracks its own consent
default, active/revoked status, and request count in the `Institutions`
sheet, so a large university system can manage access at the granularity
that matches how it actually operates, and revoke one office's access
without touching another's.

### Institutional email requirement

`college_personnel_email` must end in one of these suffixes on
`register`, `keys_list`, and `keys_revoke`: **`.edu`, `.edu.au`,
`.edu.ca`, `.edu.in`**. Anything else is rejected (`status_code: 422`)
before anything else happens. This is a domain-pattern check, not proof
that the specific mailbox exists or that the person controls it — there's
no verification email sent. To support more countries' institutional
domains (`.ac.uk`, `.ac.jp`, etc.), add them to the
`ALLOWED_EDU_SUFFIXES` array near the top of `Code.gs`.

### `action: "register"`
Issue a new API key.

```json
{
  "action": "register",
  "institution_name": "Lincoln State University",
  "college_personnel_email": "admissions@lincolnstate.edu",
  "key_label": "Undergraduate Admissions",
  "data_sharing_consent": "accept"
}
```
→
```json
{
  "ok": true,
  "status_code": 200,
  "api_key": "…",
  "institution_name": "Lincoln State University",
  "key_label": "Undergraduate Admissions",
  "data_sharing_consent": "accept",
  "registered_at": "2026-…"
}
```

### `action: "keys_list"`
Recover or audit every key issued to an institution, by supplying the
same institution name and personnel email used at registration.

```json
{
  "action": "keys_list",
  "institution_name": "Lincoln State University",
  "college_personnel_email": "admissions@lincolnstate.edu"
}
```
→ returns every key's label, consent setting, active/revoked status,
creation time, and request count.

### `action: "keys_revoke"`
Revoke a specific key. Requires the personnel email on file for that key
as a lightweight ownership check.

```json
{
  "action": "keys_revoke",
  "api_key": "…",
  "college_personnel_email": "admissions@lincolnstate.edu"
}
```

### `action: "consent"`
Update a key's default consent choice at any time.

```json
{ "action": "consent", "api_key": "…", "data_sharing_consent": "decline" }
```

## Single worksheet request

### `action: "worksheet"`
Generate one worksheet. `essay_text` is required (max 20,000 characters);
everything else is optional.

```json
{
  "action": "worksheet",
  "api_key": "…",
  "essay_text": "…the applicant's essay…",
  "academic": { "sat_rw": 650, "ap_lang": 4 },
  "background": { "first_gen_disclosed": true, "school_name": "Lincoln High" },
  "data_sharing_consent": "decline"
}
```
→ Returns `academic_snapshot`, `background_context`, `writing_observations`,
and `discussion_prompts` — descriptive lists only, plus a repeated
disclaimer. **No field in the response is, or resembles, a score.**

## Bulk requests

### `action: "worksheet_bulk"`
Generate worksheets for many applications in a single call — for an
admissions cycle with thousands of essays, this means chunking your queue
into batches (max 200 items per call — see below for why) instead of one
round trip per applicant.

```json
{
  "action": "worksheet_bulk",
  "api_key": "…",
  "items": [
    { "applicant_ref": "APP-10432", "essay_text": "…essay one…", "academic": { "sat_rw": 650 } },
    { "applicant_ref": "APP-10433", "essay_text": "…essay two…" }
  ]
}
```
→
```json
{
  "ok": true,
  "status_code": 200,
  "institution_name": "Lincoln State University",
  "submitted": 2,
  "succeeded": 2,
  "failed": 0,
  "results": [
    { "applicant_ref": "APP-10432", "status": "ok", "worksheet": { "...": "..." } },
    { "applicant_ref": "APP-10433", "status": "ok", "worksheet": { "...": "..." } }
  ]
}
```

A problem with one item (e.g. blank essay text) is reported as
`"status": "error"` with an `error` message for that item only — it does
not fail the rest of the batch. `applicant_ref` is echoed back exactly as
submitted; it is never stored on this API's side, in memory or otherwise,
beyond the lifetime of the request/response.

**Why 200 items, not 500 or more:** Apps Script executions are capped at
6 minutes, and each row written to the Sheet has real latency. 200 items
per call, with usage-analytics rows written in a single batched
spreadsheet call rather than one call per item, comfortably fits that
ceiling. For a cycle with thousands of essays, chunk client-side into
multiple calls of ≤200 rather than raising this limit.

## Integration notes for enrollment systems

- Treat `essay_text` as sensitive student data in transit and in your own
  logs — even though this API doesn't store it, your calling system
  should not log full request bodies.
- Never populate `applicant_ref`, or any free-text field like
  `school_context_notes`, with a student's name, email, or other
  identifying detail — this API doesn't need it and doesn't check for it.
- Present the consent choice to the individual admissions staff member at
  the point of use, not just once at key registration.
- Register a separate key (with a `key_label`) per office or per
  downstream system rather than sharing one key institution-wide.
- Check `ok` (and, if you want finer branching, `status_code`) in the
  response body — not the HTTP status, which is always 200 from an Apps
  Script Web App.

## Known limitations

- The institutional-email requirement checks domain *pattern* only —
  there's no verification email or mailbox-ownership proof, so it stops
  casual/non-institutional signups but not someone with a real
  institutional address who isn't authorized to register on their
  institution's behalf.
- `keys_list` and `keys_revoke` authenticate ownership with a plain
  institution-name + email match — a reasonable bar for a free tool, but
  not equivalent to a verified email flow (e.g. a one-time link).
- No hard rate limiting or per-institution quota yet — `request_count` is
  tracked per key so you can see usage, but nothing throttles it.
- Regex-based document field extraction (from the original HTML tool) is
  not part of this API — it expects essay text and structured fields
  passed directly.
- Every Google account has Apps Script execution quotas (daily URL Fetch
  calls, total execution time, etc.). These are generous for a
  single-institution tool but worth knowing about if usage grows very
  large — see Google's [Apps Script quotas documentation](https://developers.google.com/apps-script/guides/services/quotas)
  if you need specifics.
- Anyone with edit access to the underlying Google Sheet can see hashed
  emails, institution names, key labels, and (if consent was given)
  derived writing-pattern summaries — treat edit access to that Sheet the
  same way you'd treat admin access to a database.

---

## Appendix: the FastAPI alternative

If you'd rather run your own server instead of Apps Script + Sheets,
`main.py` (with `passenger_wsgi.py` for cPanel/Passenger, or plain
`uvicorn main:app` for any other host) implements the same actions as
proper REST endpoints with real HTTP status codes and SQLite persistence:

```
GET  /v1/health
POST /v1/register
POST /v1/keys/list
POST /v1/keys/revoke
POST /v1/consent
POST /v1/worksheet
POST /v1/worksheet/bulk
```

Request/response shapes are the same fields described above (minus the
`action` wrapper — the path selects the action, and HTTP status codes are
real, unlike the Apps Script version). Interactive Swagger docs are
available at `/docs` once running. See the comments at the top of
`main.py` and `passenger_wsgi.py` for cPanel deployment steps
(Application startup file: `passenger_wsgi.py`, entry point:
`application`), including the `a2wsgi` ASGI-to-WSGI bridge cPanel's
Passenger requires.

This path needs `pip install -r requirements.txt`, a Python-capable host,
and (if deployed on cPanel) cPanel's "Setup Python App" feature — exactly
the setup the Apps Script version above was built to avoid.
